)}
+
);
}
diff --git a/apps/web/e2e/tests/chat/model-selector-error.spec.ts b/apps/web/e2e/tests/chat/model-selector-error.spec.ts
index cc58963c7e..783079c4ff 100644
--- a/apps/web/e2e/tests/chat/model-selector-error.spec.ts
+++ b/apps/web/e2e/tests/chat/model-selector-error.spec.ts
@@ -442,7 +442,7 @@ test.describe("Chat model selector — persistence", () => {
await trigger.click();
await testPage.getByTestId("config-option-trigger-effort").click();
await expect(
- testPage.getByText("Controls how much reasoning the mock model uses", { exact: true }),
+ testPage.getByText("Controls the reasoning depth for the smart mock model", { exact: true }),
).toBeVisible();
await testPage.getByRole("button", { name: "Low", exact: true }).click();
await expect
diff --git a/apps/web/e2e/tests/settings/agent-profile-acp.spec.ts b/apps/web/e2e/tests/settings/agent-profile-acp.spec.ts
index 877e78b2ee..65f2ee8140 100644
--- a/apps/web/e2e/tests/settings/agent-profile-acp.spec.ts
+++ b/apps/web/e2e/tests/settings/agent-profile-acp.spec.ts
@@ -21,7 +21,10 @@ test.describe("Agent profile — ACP-first", () => {
test.setTimeout(60_000);
const { agents } = await apiClient.listAgents();
- const agent = agents[0];
+ // Provider registration order is not a stable contract. The mock agent is
+ // the fixture that advertises deterministic ACP modes, so select it by id
+ // instead of relying on the first row returned by the API.
+ const agent = agents.find((item) => item.name === "mock-agent") ?? agents[0];
const profileId = agent.profiles[0].id;
await testPage.goto(`/settings/agents/${agent.name}/profiles/${profileId}`);
@@ -134,6 +137,12 @@ test.describe("Agent profile — ACP-first", () => {
// Shared profile selectors retain the all-values summary; baseline
// compaction applies only to task chat.
await expect(selector).toHaveText("Mock Fast / High", { timeout: 10_000 });
+ await expect(testPage.getByTestId("model-config-resolution-loading")).toBeHidden({
+ timeout: 15_000,
+ });
+ await expect(testPage.getByTestId("profile-refresh-capabilities")).toBeEnabled({
+ timeout: 15_000,
+ });
await selector.click();
await expect(
@@ -145,6 +154,30 @@ test.describe("Agent profile — ACP-first", () => {
await testPage.getByRole("button", { name: "Low", exact: true }).click();
await expect(selector).toHaveText("Mock Fast / Low");
+ // Changing the model must replace the option snapshot. mock-smart
+ // exposes Max while mock-fast exposes Medium, so this also proves the
+ // profile selector does not keep the previous model's option list.
+ await testPage.getByRole("option", { name: /Mock Smart/ }).click();
+ await expect(testPage.getByTestId("model-config-resolution-loading")).toBeHidden({
+ timeout: 15_000,
+ });
+ await expect(selector).toContainText("Mock Smart", { timeout: 10_000 });
+ await expect(testPage.getByTestId("config-option-trigger-effort")).toBeVisible();
+ await testPage.getByTestId("config-option-trigger-effort").click();
+ await expect(testPage.getByRole("button", { name: "Max", exact: true })).toBeVisible();
+ await testPage.getByRole("button", { name: "Max", exact: true }).click();
+ await expect(selector).toHaveText("Mock Smart / Max");
+
+ // Restore the original model before exercising persistence below.
+ await testPage.getByRole("option", { name: /Mock Fast/ }).click();
+ await expect(testPage.getByTestId("model-config-resolution-loading")).toBeHidden({
+ timeout: 15_000,
+ });
+ await testPage.getByTestId("config-option-trigger-effort").click();
+ await testPage.getByRole("button", { name: "Low", exact: true }).click();
+ await expect(selector).toHaveText("Mock Fast / Low");
+ await selector.click();
+
const saveButton = testPage.getByRole("button", { name: /^Save( changes)?$/i }).first();
await expect(saveButton).toBeEnabled({ timeout: 10_000 });
await saveButton.click();
@@ -181,7 +214,7 @@ test.describe("Agent profile — ACP-first", () => {
// 1. Get mock agent and create a profile with mode "plan-mock"
const { agents } = await apiClient.listAgents();
- const agent = agents[0];
+ const agent = agents.find((item) => item.name === "mock-agent") ?? agents[0];
const profile = await apiClient.createAgentProfile(agent.id, "Mode Test Profile", {
model: "mock-fast",
mode: "plan-mock",
diff --git a/apps/web/e2e/tests/settings/mobile-agent-profile-config-selector.spec.ts b/apps/web/e2e/tests/settings/mobile-agent-profile-config-selector.spec.ts
index 1a3ba3d2a5..4afc266e39 100644
--- a/apps/web/e2e/tests/settings/mobile-agent-profile-config-selector.spec.ts
+++ b/apps/web/e2e/tests/settings/mobile-agent-profile-config-selector.spec.ts
@@ -35,12 +35,26 @@ test.describe("Mobile agent profile config selector", () => {
await testPage.goto(`/settings/agents/${agent.name}/profiles/${profile.id}`);
const selector = testPage.getByRole("button", { name: "Profile start model settings" });
await expect(selector).toBeVisible({ timeout: 15_000 });
+ await expect(testPage.getByTestId("model-config-resolution-loading")).toBeHidden({
+ timeout: 15_000,
+ });
+ await expect(testPage.getByTestId("profile-refresh-capabilities")).toBeEnabled({
+ timeout: 15_000,
+ });
await selector.click();
+ await testPage.getByRole("option", { name: /Mock Smart/ }).tap();
+ await expect(testPage.getByTestId("model-config-resolution-loading")).toBeHidden({
+ timeout: 15_000,
+ });
+ await expect(selector).toContainText("Mock Smart", { timeout: 10_000 });
const effortTrigger = testPage.getByTestId("config-option-trigger-effort");
await expect(effortTrigger).toBeVisible();
await effortTrigger.click();
- await testPage.getByRole("button", { name: "High", exact: true }).click();
- await expect(selector).toContainText("High");
+ await expect(testPage.getByRole("button", { name: "Max", exact: true })).toBeVisible({
+ timeout: 10_000,
+ });
+ await testPage.getByRole("button", { name: "Max", exact: true }).tap();
+ await expect(selector).toHaveText("Mock Smart / Max");
} finally {
await apiClient.deleteAgentProfile(profile.id, true);
}
diff --git a/apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts b/apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts
index 3a0cce0b98..efde164b00 100644
--- a/apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts
+++ b/apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts
@@ -38,7 +38,7 @@ test.describe("Workflow settings on mobile", () => {
await settings.tap();
await testPage.getByText("Mock Smart", { exact: true }).tap();
await testPage.getByTestId("config-option-trigger-effort").tap();
- await testPage.getByRole("button", { name: "High", exact: true }).tap();
+ await testPage.getByRole("button", { name: "Max", exact: true }).tap();
await testPage.keyboard.press("Escape");
await prCapture.screenshot("mobile-original-session-editor", {
caption: "Mobile workflow step editor with touch-sized original-session controls.",
@@ -65,7 +65,7 @@ test.describe("Workflow settings on mobile", () => {
{
operation: "set",
model: "mock-smart",
- config_options: { effort: "high" },
+ config_options: { effort: "max" },
},
],
},
diff --git a/apps/web/e2e/tests/workflow/workflow-settings.spec.ts b/apps/web/e2e/tests/workflow/workflow-settings.spec.ts
index 2b5c2c4428..d820c200bb 100644
--- a/apps/web/e2e/tests/workflow/workflow-settings.spec.ts
+++ b/apps/web/e2e/tests/workflow/workflow-settings.spec.ts
@@ -81,17 +81,37 @@ test.describe("Workflow settings", () => {
seedData,
prCapture,
}) => {
+ // The host-utility probe runs asynchronously during backend startup. Wait
+ // for the deterministic mock provider used by this spec instead of
+ // selecting whichever provider happens to be first in the response.
+ await expect
+ .poll(
+ async () => {
+ const available = await testPage.request.get(
+ `${backend.baseUrl}/api/v1/agents/available`,
+ );
+ if (!available.ok()) return false;
+ const payload = (await available.json()) as {
+ agents?: Array<{
+ name: string;
+ model_config?: { config_options?: Array<{ id: string }> };
+ }>;
+ };
+ const mock = payload.agents?.find((item) => item.name === "mock-agent");
+ return Boolean(
+ mock?.model_config?.config_options?.some((option) => option.id === "effort"),
+ );
+ },
+ { timeout: 20_000, intervals: [250, 500, 1_000] },
+ )
+ .toBe(true);
+
const available = await testPage.request.get(`${backend.baseUrl}/api/v1/agents/available`);
expect(available.ok()).toBe(true);
const availablePayload = (await available.json()) as {
- agents?: Array<{
- name: string;
- model_config?: { config_options?: Array<{ id: string }> };
- }>;
+ agents?: Array<{ name: string }>;
};
- const agent = availablePayload.agents?.find((item) =>
- item.model_config?.config_options?.some((option) => option.id === "effort"),
- );
+ const agent = availablePayload.agents?.find((item) => item.name === "mock-agent");
expect(agent).toBeDefined();
const workflow = await apiClient.createWorkflow(
@@ -124,7 +144,7 @@ test.describe("Workflow settings", () => {
await settings.click();
await testPage.getByText("Mock Smart", { exact: true }).click();
await testPage.getByTestId("config-option-trigger-effort").click();
- await testPage.getByRole("button", { name: "High", exact: true }).click();
+ await testPage.getByRole("button", { name: "Max", exact: true }).click();
await testPage.keyboard.press("Escape");
await prCapture.screenshot("desktop-original-session-editor", {
caption: "Workflow step editor with a conditional original-session model and effort rule.",
@@ -141,7 +161,7 @@ test.describe("Workflow settings", () => {
agent_name: agent!.name,
operation: "set",
model: "mock-smart",
- config_options: { effort: "high" },
+ config_options: { effort: "max" },
},
],
},
diff --git a/apps/web/hooks/domains/settings/use-available-agents.ts b/apps/web/hooks/domains/settings/use-available-agents.ts
index bfe911a000..84ef05e2a0 100644
--- a/apps/web/hooks/domains/settings/use-available-agents.ts
+++ b/apps/web/hooks/domains/settings/use-available-agents.ts
@@ -1,6 +1,19 @@
import { useEffect } from "react";
import { useAppStore } from "@/components/state-provider";
import { listAvailableAgents } from "@/lib/api";
+import type { AvailableAgent } from "@/lib/types/http";
+
+const CAPABILITY_REVALIDATION_DELAY_MS = 250;
+const CAPABILITY_REVALIDATION_MAX_ATTEMPTS = 240;
+let capabilityRevalidation: Promise | null = null;
+
+function hasPendingDynamicCapabilities(agents: AvailableAgent[]): boolean {
+ return agents.some(
+ (agent) =>
+ agent.model_config.supports_dynamic_models &&
+ (agent.model_config.status === "not_configured" || agent.model_config.status === "probing"),
+ );
+}
export function useAvailableAgents(enabled = true) {
const availableAgents = useAppStore((state) => state.availableAgents);
@@ -24,5 +37,37 @@ export function useAvailableAgents(enabled = true) {
setAvailableAgentsLoading,
]);
+ useEffect(() => {
+ if (
+ !enabled ||
+ !availableAgents.loaded ||
+ availableAgents.loading ||
+ !hasPendingDynamicCapabilities(availableAgents.items) ||
+ capabilityRevalidation
+ ) {
+ return;
+ }
+
+ capabilityRevalidation = (async () => {
+ for (let attempt = 0; attempt <= CAPABILITY_REVALIDATION_MAX_ATTEMPTS; attempt += 1) {
+ const response = await listAvailableAgents({ cache: "no-store" });
+ setAvailableAgents(response.agents, response.tools ?? []);
+ if (!hasPendingDynamicCapabilities(response.agents)) return;
+ if (attempt === CAPABILITY_REVALIDATION_MAX_ATTEMPTS) return;
+ await new Promise((resolve) => setTimeout(resolve, CAPABILITY_REVALIDATION_DELAY_MS));
+ }
+ })()
+ .catch(() => undefined)
+ .finally(() => {
+ capabilityRevalidation = null;
+ });
+ }, [
+ availableAgents.items,
+ availableAgents.loaded,
+ availableAgents.loading,
+ enabled,
+ setAvailableAgents,
+ ]);
+
return availableAgents;
}
diff --git a/apps/web/hooks/domains/settings/use-dynamic-models.test.ts b/apps/web/hooks/domains/settings/use-dynamic-models.test.ts
index ca093a60a7..533c9353a2 100644
--- a/apps/web/hooks/domains/settings/use-dynamic-models.test.ts
+++ b/apps/web/hooks/domains/settings/use-dynamic-models.test.ts
@@ -1,15 +1,26 @@
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
-import type { DynamicModelsResponse, ModelConfig } from "@/lib/types/http";
+import type {
+ AgentModelConfigResponse,
+ ConfigOptionEntry,
+ DynamicModelsResponse,
+ ModelConfig,
+} from "@/lib/types/http";
const fetchDynamicModelsMock = vi.fn();
+const resolveAgentModelConfigMock = vi.fn();
vi.mock("@/lib/api/domains/settings-api", () => ({
fetchDynamicModels: (...args: unknown[]) => fetchDynamicModelsMock(...args),
+ resolveAgentModelConfig: (...args: unknown[]) => resolveAgentModelConfigMock(...args),
}));
-import { useAgentCapabilities } from "./use-dynamic-models";
+import {
+ invalidateModelConfigResolutionCache,
+ useAgentCapabilities,
+ useResolvedModelConfig,
+} from "./use-dynamic-models";
const initialConfig: ModelConfig = {
default_model: "",
@@ -34,9 +45,24 @@ function response(status: DynamicModelsResponse["status"]): DynamicModelsRespons
afterEach(() => {
cleanup();
+ invalidateModelConfigResolutionCache();
fetchDynamicModelsMock.mockReset();
+ resolveAgentModelConfigMock.mockReset();
});
+function resolvedResponse(
+ model: string,
+ configOptions: ConfigOptionEntry[],
+): AgentModelConfigResponse {
+ return {
+ agent_name: "opencode",
+ model,
+ status: "ok",
+ config_options: configOptions,
+ error: null,
+ };
+}
+
describe("useAgentCapabilities", () => {
it("exposes the status returned by a forced capability refresh", async () => {
fetchDynamicModelsMock
@@ -146,4 +172,171 @@ describe("useAgentCapabilities", () => {
await waitFor(() => expect(result.current.error).toBe("probe timed out"));
});
+
+ it("polls a pending bootstrap until the capability snapshot is ready", async () => {
+ fetchDynamicModelsMock
+ .mockResolvedValueOnce(response("not_configured"))
+ .mockResolvedValueOnce(response("probing"))
+ .mockResolvedValueOnce({
+ ...response("ok"),
+ modes: [{ id: "plan", name: "Plan" }],
+ });
+
+ const { result } = renderHook(() => useAgentCapabilities("grok-acp", initialConfig));
+
+ await waitFor(
+ () => {
+ expect(result.current.modes).toEqual([{ id: "plan", name: "Plan" }]);
+ expect(result.current.isLoading).toBe(false);
+ },
+ { timeout: 2_000 },
+ );
+ expect(fetchDynamicModelsMock).toHaveBeenCalledTimes(3);
+ });
+});
+
+describe("useResolvedModelConfig", () => {
+ it("loads options for the selected model and ignores an older response", async () => {
+ let resolveFirst: ((response: AgentModelConfigResponse) => void) | undefined;
+ let resolveSecond: ((response: AgentModelConfigResponse) => void) | undefined;
+ resolveAgentModelConfigMock.mockImplementation((_, request: { model: string }) => {
+ if (request.model === "model-a") {
+ return new Promise((resolve) => {
+ resolveFirst = resolve;
+ });
+ }
+ return new Promise((resolve) => {
+ resolveSecond = resolve;
+ });
+ });
+
+ const baseline: ConfigOptionEntry[] = [
+ { type: "select", id: "effort", name: "Effort", current_value: "low", options: [] },
+ ];
+ const { result, rerender } = renderHook(
+ ({ model }) =>
+ useResolvedModelConfig("opencode", model, {
+ mode: "build",
+ configOptions: { effort: "low" },
+ initialConfigOptions: baseline,
+ }),
+ { initialProps: { model: "model-a" } },
+ );
+
+ await waitFor(() => expect(resolveAgentModelConfigMock).toHaveBeenCalledTimes(1));
+ expect(resolveAgentModelConfigMock).toHaveBeenLastCalledWith("opencode", {
+ model: "model-a",
+ mode: "build",
+ config_options: { effort: "low" },
+ });
+
+ rerender({ model: "model-b" });
+ await waitFor(() => expect(resolveAgentModelConfigMock).toHaveBeenCalledTimes(2));
+
+ const secondOptions: ConfigOptionEntry[] = [
+ {
+ type: "select",
+ id: "reasoning_effort",
+ name: "Reasoning effort",
+ current_value: "max",
+ options: [],
+ },
+ ];
+ await act(async () => {
+ resolveSecond?.(resolvedResponse("model-b", secondOptions));
+ });
+ expect(result.current.configOptions).toEqual(secondOptions);
+
+ await act(async () => {
+ resolveFirst?.(resolvedResponse("model-a", baseline));
+ });
+ expect(result.current.configOptions).toEqual(secondOptions);
+ });
+});
+
+describe("useResolvedModelConfig draft updates", () => {
+ it("does not re-resolve for an equivalent draft option object", async () => {
+ const configOptions: ConfigOptionEntry[] = [
+ {
+ type: "select",
+ id: "effort",
+ name: "Effort",
+ current_value: "low",
+ options: [
+ { value: "low", name: "Low" },
+ { value: "high", name: "High" },
+ ],
+ },
+ ];
+ fetchDynamicModelsMock.mockResolvedValue(response("ok"));
+ resolveAgentModelConfigMock.mockResolvedValue(
+ resolvedResponse("profile-draft-model", configOptions),
+ );
+
+ const { rerender } = renderHook(
+ ({ draft }) =>
+ useResolvedModelConfig("profile-draft-agent", "profile-draft-model", {
+ configOptions: draft,
+ initialConfigOptions: configOptions,
+ }),
+ { initialProps: { draft: { effort: "low" } } },
+ );
+
+ await waitFor(() => expect(resolveAgentModelConfigMock).toHaveBeenCalledTimes(1));
+
+ rerender({ draft: { effort: "low" } });
+ await act(async () => Promise.resolve());
+
+ expect(resolveAgentModelConfigMock).toHaveBeenCalledTimes(1);
+ expect(resolveAgentModelConfigMock).toHaveBeenCalledWith("profile-draft-agent", {
+ model: "profile-draft-model",
+ config_options: { effort: "low" },
+ });
+ });
+});
+
+describe("useResolvedModelConfig cache invalidation", () => {
+ const cacheRefreshModel = "cache-refresh-model";
+
+ it("invalidates model option resolutions when capabilities refresh", async () => {
+ const firstOptions: ConfigOptionEntry[] = [
+ { type: "select", id: "effort", name: "Effort", current_value: "low", options: [] },
+ ];
+ const secondOptions: ConfigOptionEntry[] = [
+ { type: "select", id: "effort", name: "Effort", current_value: "high", options: [] },
+ ];
+ fetchDynamicModelsMock.mockResolvedValue(response("ok"));
+ resolveAgentModelConfigMock
+ .mockResolvedValueOnce(resolvedResponse(cacheRefreshModel, firstOptions))
+ .mockResolvedValueOnce(resolvedResponse(cacheRefreshModel, secondOptions));
+
+ const modelConfig: ModelConfig = {
+ ...initialConfig,
+ current_model_id: cacheRefreshModel,
+ default_model: cacheRefreshModel,
+ };
+ const cacheRefreshAgent = "cache-refresh-agent";
+ const firstResolution = renderHook(() =>
+ useResolvedModelConfig(cacheRefreshAgent, cacheRefreshModel, {
+ initialConfigOptions: firstOptions,
+ }),
+ );
+ await waitFor(() => expect(resolveAgentModelConfigMock).toHaveBeenCalledTimes(1));
+
+ const capabilities = renderHook(() => useAgentCapabilities(cacheRefreshAgent, modelConfig));
+ await waitFor(() => expect(fetchDynamicModelsMock).toHaveBeenCalledTimes(1));
+ await act(async () => {
+ await capabilities.result.current.refresh();
+ });
+
+ const secondResolution = renderHook(() =>
+ useResolvedModelConfig(cacheRefreshAgent, cacheRefreshModel, {
+ initialConfigOptions: firstOptions,
+ }),
+ );
+ await waitFor(() =>
+ expect(secondResolution.result.current.configOptions).toEqual(secondOptions),
+ );
+ expect(firstResolution.result.current.configOptions).toEqual(firstOptions);
+ });
});
diff --git a/apps/web/hooks/domains/settings/use-dynamic-models.ts b/apps/web/hooks/domains/settings/use-dynamic-models.ts
index 47df885955..836c474b4d 100644
--- a/apps/web/hooks/domains/settings/use-dynamic-models.ts
+++ b/apps/web/hooks/domains/settings/use-dynamic-models.ts
@@ -1,12 +1,15 @@
-import { useState, useEffect, useCallback } from "react";
-import { fetchDynamicModels } from "@/lib/api/domains/settings-api";
+import { useState, useEffect, useCallback, useMemo, useRef } from "react";
+import { fetchDynamicModels, resolveAgentModelConfig } from "@/lib/api/domains/settings-api";
import type {
+ AgentModelConfigResponse,
CommandEntry,
+ ConfigOptionEntry,
ModeEntry,
ModelEntry,
CapabilityStatus,
DynamicModelsResponse,
ModelConfig,
+ ResolveAgentModelConfigRequest,
} from "@/lib/types/http";
// Set from an async catch rather than rendered as a literal, so this uses the
// module-level `t`, which resolves at call time. The message surfaces in the
@@ -25,6 +28,186 @@ type UseAgentCapabilitiesState = {
refresh: () => Promise;
};
+type UseResolvedModelConfigOptions = {
+ mode?: string;
+ configOptions?: Record;
+ initialConfigOptions?: ConfigOptionEntry[];
+ enabled?: boolean;
+};
+
+type UseResolvedModelConfigState = {
+ configOptions: ConfigOptionEntry[];
+ status: CapabilityStatus | undefined;
+ isResolvedForRequest: boolean;
+ isResolutionPending: boolean;
+ isLoading: boolean;
+ error: string | null;
+ refresh: () => Promise;
+};
+
+type CachedModelConfigResolution = {
+ response: AgentModelConfigResponse;
+ expiresAt: number;
+};
+
+const MODEL_CONFIG_RESOLUTION_TTL_MS = 5 * 60 * 1000;
+const CAPABILITY_PENDING_POLL_DELAY_MS = 250;
+const CAPABILITY_PENDING_MAX_ATTEMPTS = 240;
+const modelConfigResolutionCache = new Map();
+const modelConfigResolutionInflight = new Map>();
+let modelConfigResolutionGeneration = 0;
+
+export function invalidateModelConfigResolutionCache(agentName?: string): void {
+ modelConfigResolutionGeneration += 1;
+ if (!agentName) {
+ modelConfigResolutionCache.clear();
+ modelConfigResolutionInflight.clear();
+ return;
+ }
+ for (const key of modelConfigResolutionCache.keys()) {
+ if (modelConfigResolutionKeyAgent(key) === agentName) {
+ modelConfigResolutionCache.delete(key);
+ }
+ }
+ for (const key of modelConfigResolutionInflight.keys()) {
+ if (modelConfigResolutionKeyAgent(key) === agentName) {
+ modelConfigResolutionInflight.delete(key);
+ }
+ }
+}
+
+export function __resetModelConfigResolutionCache(): void {
+ modelConfigResolutionGeneration += 1;
+ modelConfigResolutionCache.clear();
+ modelConfigResolutionInflight.clear();
+}
+
+function modelConfigResolutionKeyAgent(key: string): string | undefined {
+ try {
+ const parsed = JSON.parse(key);
+ return Array.isArray(parsed) && typeof parsed[0] === "string" ? parsed[0] : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+function stableRecordKey(values: Record | undefined): string {
+ return JSON.stringify(
+ Object.entries(values ?? {}).sort(([left], [right]) => left.localeCompare(right)),
+ );
+}
+
+function modelConfigResolutionKey(
+ agentName: string,
+ request: ResolveAgentModelConfigRequest,
+): string {
+ return JSON.stringify([
+ agentName,
+ request.model,
+ request.mode ?? "",
+ stableRecordKey(request.config_options),
+ ]);
+}
+
+function cloneModelConfigResponse(response: AgentModelConfigResponse): AgentModelConfigResponse {
+ return {
+ ...response,
+ config_options: (response.config_options ?? []).map((option) => ({
+ ...option,
+ options: option.options?.map((item) => ({ ...item })),
+ })),
+ };
+}
+
+async function fetchResolvedModelConfig(
+ agentName: string,
+ request: ResolveAgentModelConfigRequest,
+ forceRefresh: boolean,
+): Promise {
+ const key = modelConfigResolutionKey(agentName, request);
+ if (!forceRefresh) {
+ const cached = modelConfigResolutionCache.get(key);
+ if (cached && cached.expiresAt > Date.now()) {
+ return cloneModelConfigResponse(cached.response);
+ }
+ if (cached) {
+ modelConfigResolutionCache.delete(key);
+ }
+ }
+
+ const existing = modelConfigResolutionInflight.get(key);
+ if (existing) return existing.then(cloneModelConfigResponse);
+
+ const generation = modelConfigResolutionGeneration;
+ const payload = forceRefresh ? { ...request, refresh: true } : request;
+ const promise = resolveAgentModelConfig(agentName, payload).then((response) => {
+ const cloned = cloneModelConfigResponse(response);
+ if (cloned.status === "ok" && generation === modelConfigResolutionGeneration) {
+ modelConfigResolutionCache.set(key, {
+ response: cloned,
+ expiresAt: Date.now() + MODEL_CONFIG_RESOLUTION_TTL_MS,
+ });
+ }
+ return cloned;
+ });
+ modelConfigResolutionInflight.set(key, promise);
+ void promise.then(
+ () => {
+ if (modelConfigResolutionInflight.get(key) === promise) {
+ modelConfigResolutionInflight.delete(key);
+ }
+ },
+ () => {
+ if (modelConfigResolutionInflight.get(key) === promise) {
+ modelConfigResolutionInflight.delete(key);
+ }
+ },
+ );
+ return promise.then(cloneModelConfigResponse);
+}
+
+function isCapabilityProbePending(status: CapabilityStatus): boolean {
+ return status === "not_configured" || status === "probing";
+}
+
+async function fetchCapabilitiesUntilSettled(
+ agentName: string,
+ forceRefresh: boolean,
+): Promise {
+ for (let attempt = 0; ; attempt += 1) {
+ const response = await fetchDynamicModels(agentName, { refresh: forceRefresh });
+ if (forceRefresh || !isCapabilityProbePending(response.status)) {
+ return response;
+ }
+ if (attempt >= CAPABILITY_PENDING_MAX_ATTEMPTS) {
+ return response;
+ }
+ await new Promise((resolve) => setTimeout(resolve, CAPABILITY_PENDING_POLL_DELAY_MS));
+ }
+}
+
+type ModelConfigResolutionResult =
+ | { kind: "response"; response: AgentModelConfigResponse }
+ | { kind: "error"; error: string };
+
+async function resolveModelConfig(
+ agentName: string,
+ request: ResolveAgentModelConfigRequest,
+ forceRefresh: boolean,
+): Promise {
+ try {
+ return {
+ kind: "response",
+ response: await fetchResolvedModelConfig(agentName, request, forceRefresh),
+ };
+ } catch (err) {
+ return {
+ kind: "error",
+ error: err instanceof Error ? err.message : t("agents:failedToFetchCapabilities"),
+ };
+ }
+}
+
/**
* useAgentCapabilities fetches the full ACP probe cache for an agent
* (models, modes, current defaults) and keeps it in sync. Refresh triggers
@@ -49,24 +232,28 @@ export function useAgentCapabilities(
const hasManualRefresh = manualRefreshAgentName === agentName;
const [isLoading, setIsLoading] = useState(supportsDynamicModels && !!agentName);
const [error, setError] = useState(null);
+ const capabilityRequestSequence = useRef(0);
const fetchCaps = useCallback(
async (forceRefresh: boolean) => {
if (!agentName || !supportsDynamicModels) {
return;
}
+ const sequence = ++capabilityRequestSequence.current;
setIsLoading(true);
+ if (forceRefresh) {
+ invalidateModelConfigResolutionCache(agentName);
+ }
setError(null);
try {
- const response: DynamicModelsResponse = await fetchDynamicModels(agentName, {
- refresh: forceRefresh,
- });
+ const response = await fetchCapabilitiesUntilSettled(agentName, forceRefresh);
+ if (sequence !== capabilityRequestSequence.current) return;
setStatus(response.status);
setError(response.error ?? null);
if (forceRefresh) {
setManualRefreshAgentName(agentName);
}
- if (response.status !== "failed") {
+ if (response.status === "ok") {
setModels(response.models ?? []);
setModes(response.modes ?? []);
setCommands(response.commands ?? []);
@@ -74,9 +261,12 @@ export function useAgentCapabilities(
setCurrentModeId(response.current_mode_id);
}
} catch (err) {
+ if (sequence !== capabilityRequestSequence.current) return;
setError(err instanceof Error ? err.message : t("agents:failedToFetchCapabilities"));
} finally {
- setIsLoading(false);
+ if (sequence === capabilityRequestSequence.current) {
+ setIsLoading(false);
+ }
}
},
[agentName, supportsDynamicModels],
@@ -92,6 +282,9 @@ export function useAgentCapabilities(
if (supportsDynamicModels && agentName) {
void fetchCaps(false);
}
+ return () => {
+ capabilityRequestSequence.current += 1;
+ };
}, [agentName, supportsDynamicModels, fetchCaps]);
const refresh = useCallback(() => fetchCaps(true), [fetchCaps]);
@@ -108,3 +301,108 @@ export function useAgentCapabilities(
refresh,
};
}
+
+/**
+ * Resolve the complete provider option snapshot for the selected model.
+ *
+ * The response is model-aware. A provider can therefore return a different
+ * option list for every model without adding provider-specific frontend code.
+ */
+export function useResolvedModelConfig(
+ agentName: string | undefined,
+ model: string | undefined,
+ options: UseResolvedModelConfigOptions = {},
+): UseResolvedModelConfigState {
+ const { mode, configOptions, initialConfigOptions = [], enabled = true } = options;
+ const configOptionsKey = stableRecordKey(configOptions);
+ const initialConfigOptionsKey = JSON.stringify(initialConfigOptions);
+ const stableConfigOptions = useMemo(() => configOptions, [configOptionsKey]);
+ const stableInitialConfigOptions = useMemo(() => initialConfigOptions, [initialConfigOptionsKey]);
+ const request = useMemo(() => {
+ if (!model) return null;
+ return {
+ model,
+ ...(mode ? { mode } : {}),
+ ...(stableConfigOptions && Object.keys(stableConfigOptions).length > 0
+ ? { config_options: stableConfigOptions }
+ : {}),
+ };
+ }, [mode, model, stableConfigOptions]);
+ const requestKey = useMemo(
+ () => (request ? modelConfigResolutionKey(agentName ?? "", request) : undefined),
+ [agentName, request],
+ );
+ const [resolvedOptions, setResolvedOptions] = useState(
+ stableInitialConfigOptions,
+ );
+ const [status, setStatus] = useState();
+ const [resolvedRequestKey, setResolvedRequestKey] = useState();
+ const [settledRequestKey, setSettledRequestKey] = useState();
+ const [isLoading, setIsLoading] = useState(Boolean(enabled && agentName && request));
+ const [error, setError] = useState(null);
+ const requestSequence = useRef(0);
+
+ const run = useCallback(
+ async (forceRefresh: boolean) => {
+ const sequence = ++requestSequence.current;
+ if (!enabled || !agentName || !request) {
+ setResolvedOptions(stableInitialConfigOptions);
+ setStatus(undefined);
+ setResolvedRequestKey(undefined);
+ setSettledRequestKey(undefined);
+ setError(null);
+ setIsLoading(false);
+ return;
+ }
+
+ setResolvedRequestKey(undefined);
+ setSettledRequestKey(undefined);
+ setResolvedOptions(stableInitialConfigOptions);
+ setStatus(forceRefresh ? "probing" : undefined);
+ setError(null);
+ setIsLoading(true);
+ const result = await resolveModelConfig(agentName, request, forceRefresh);
+ if (sequence !== requestSequence.current) return;
+ if (result.kind === "error") {
+ setStatus("failed");
+ setError(result.error);
+ setResolvedOptions(stableInitialConfigOptions);
+ if (requestKey) setSettledRequestKey(requestKey);
+ } else {
+ const response = result.response;
+ setStatus(response.status);
+ setError(response.error ?? null);
+ setResolvedOptions(
+ response.status === "ok" ? (response.config_options ?? []) : stableInitialConfigOptions,
+ );
+ if (response.status === "ok" && requestKey) {
+ setResolvedRequestKey(requestKey);
+ }
+ if (requestKey) setSettledRequestKey(requestKey);
+ }
+ setIsLoading(false);
+ },
+ [agentName, enabled, request, requestKey, stableInitialConfigOptions],
+ );
+
+ useEffect(() => {
+ void run(false);
+ return () => {
+ requestSequence.current += 1;
+ };
+ }, [run]);
+
+ const refresh = useCallback(() => run(true), [run]);
+
+ return {
+ configOptions: resolvedOptions,
+ status,
+ isResolvedForRequest: requestKey !== undefined && resolvedRequestKey === requestKey,
+ isResolutionPending: Boolean(
+ enabled && agentName && requestKey && (isLoading || settledRequestKey !== requestKey),
+ ),
+ isLoading,
+ error,
+ refresh,
+ };
+}
diff --git a/apps/web/hooks/domains/settings/use-profile-model-capabilities.ts b/apps/web/hooks/domains/settings/use-profile-model-capabilities.ts
new file mode 100644
index 0000000000..864aec3cf2
--- /dev/null
+++ b/apps/web/hooks/domains/settings/use-profile-model-capabilities.ts
@@ -0,0 +1,70 @@
+import { useCallback, useEffect, useRef } from "react";
+import { useAgentCapabilities, useResolvedModelConfig } from "./use-dynamic-models";
+import type { ModelConfig } from "@/lib/types/http";
+import { reconcileConfigOptionValues } from "@/components/settings/profile-model-config";
+
+type ProfileModelSelection = {
+ model: string;
+ mode: string;
+ config_options?: Record;
+};
+
+export function useProfileModelCapabilities(
+ agentName: string,
+ profile: ProfileModelSelection,
+ modelConfig: ModelConfig,
+ onChange?: (patch: { config_options: Record }) => void,
+) {
+ const capabilities = useAgentCapabilities(agentName, modelConfig);
+ const selectedModel =
+ profile.model ||
+ capabilities.currentModelId ||
+ modelConfig.current_model_id ||
+ modelConfig.default_model;
+ const initialProfileModel = useRef(profile.model);
+ const hasUserSelectedModel = useRef(false);
+ const resolvedModelConfig = useResolvedModelConfig(agentName, selectedModel, {
+ initialConfigOptions: modelConfig.config_options,
+ enabled: modelConfig.supports_dynamic_models,
+ });
+
+ useEffect(() => {
+ if (profile.model !== initialProfileModel.current) {
+ hasUserSelectedModel.current = true;
+ }
+ }, [profile.model]);
+
+ const shouldReconcile =
+ Boolean(onChange) &&
+ hasUserSelectedModel.current &&
+ resolvedModelConfig.status === "ok" &&
+ resolvedModelConfig.isResolvedForRequest;
+ const nextConfigOptions = shouldReconcile
+ ? reconcileConfigOptionValues(profile.config_options, resolvedModelConfig.configOptions)
+ : (profile.config_options ?? {});
+ const needsReconciliation =
+ shouldReconcile &&
+ JSON.stringify(nextConfigOptions) !== JSON.stringify(profile.config_options ?? {});
+
+ useEffect(() => {
+ if (!onChange || !shouldReconcile || !needsReconciliation) {
+ return;
+ }
+ onChange({ config_options: nextConfigOptions });
+ }, [needsReconciliation, onChange, profile.config_options, nextConfigOptions, shouldReconcile]);
+
+ const refresh = useCallback(async () => {
+ await Promise.all([capabilities.refresh(), resolvedModelConfig.refresh()]);
+ }, [capabilities.refresh, resolvedModelConfig.refresh]);
+
+ return {
+ capabilities,
+ configOptions: resolvedModelConfig.configOptions,
+ configStatus: resolvedModelConfig.status,
+ configError: resolvedModelConfig.error,
+ configIsLoading: resolvedModelConfig.isLoading,
+ isConfigResolutionPending: resolvedModelConfig.isResolutionPending || needsReconciliation,
+ refreshModelConfig: resolvedModelConfig.refresh,
+ refresh,
+ };
+}
diff --git a/apps/web/lib/api/domains/settings-api.test.ts b/apps/web/lib/api/domains/settings-api.test.ts
index 22a67b1030..643a704b53 100644
--- a/apps/web/lib/api/domains/settings-api.test.ts
+++ b/apps/web/lib/api/domains/settings-api.test.ts
@@ -7,6 +7,7 @@ vi.mock("@/lib/config", () => ({
import {
fetchMessageQueueSettings,
fetchSleepInhibitionSettings,
+ resolveAgentModelConfig,
startHostShell,
updateMessageQueueSettings,
updateSleepInhibitionSettings,
@@ -135,3 +136,31 @@ describe("startHostShell", () => {
expect(JSON.parse(String(fetchCall?.[1]?.body))).toEqual({ cols: 80, rows: 24 });
});
});
+
+describe("resolveAgentModelConfig", () => {
+ it("posts the selected provider context", async () => {
+ fetchSpy.mockResolvedValueOnce(
+ jsonResponse({
+ agent_name: "opencode",
+ model: "opencode/gpt-5.6",
+ status: "ok",
+ config_options: [],
+ error: null,
+ }),
+ );
+
+ await resolveAgentModelConfig("opencode", {
+ model: "opencode/gpt-5.6",
+ mode: "build",
+ config_options: { reasoning_effort: "high" },
+ });
+
+ expect(lastCall().url).toBe("http://api.test/api/v1/agent-models/opencode/resolve");
+ expect(lastCall().init?.method).toBe("POST");
+ expect(JSON.parse(String(lastCall().init?.body))).toEqual({
+ model: "opencode/gpt-5.6",
+ mode: "build",
+ config_options: { reasoning_effort: "high" },
+ });
+ });
+});
diff --git a/apps/web/lib/api/domains/settings-api.ts b/apps/web/lib/api/domains/settings-api.ts
index aa545107d4..4a9939d018 100644
--- a/apps/web/lib/api/domains/settings-api.ts
+++ b/apps/web/lib/api/domains/settings-api.ts
@@ -19,6 +19,8 @@ import type {
UserSettingsResponse,
UserSettingsUpdatePayload,
DynamicModelsResponse,
+ AgentModelConfigResponse,
+ ResolveAgentModelConfigRequest,
} from "@/lib/types/http";
import type {
MessageQueueSettingsPatch,
@@ -349,6 +351,17 @@ export async function fetchDynamicModels(
return fetchJson(url, options);
}
+export async function resolveAgentModelConfig(
+ agentName: string,
+ payload: ResolveAgentModelConfigRequest,
+ options?: ApiRequestOptions,
+): Promise {
+ return fetchJson(`/api/v1/agent-models/${agentName}/resolve`, {
+ ...options,
+ init: { method: "POST", body: JSON.stringify(payload), ...(options?.init ?? {}) },
+ });
+}
+
// Editors
export async function listEditors(options?: ApiRequestOptions) {
return fetchJson("/api/v1/editors", options);
diff --git a/apps/web/lib/types/http-agents.ts b/apps/web/lib/types/http-agents.ts
index 8c58230f67..37b9c05c92 100644
--- a/apps/web/lib/types/http-agents.ts
+++ b/apps/web/lib/types/http-agents.ts
@@ -159,6 +159,21 @@ export type DynamicModelsResponse = {
error: string | null;
};
+export type ResolveAgentModelConfigRequest = {
+ model: string;
+ mode?: string;
+ config_options?: Record;
+ refresh?: boolean;
+};
+
+export type AgentModelConfigResponse = {
+ agent_name: string;
+ model: string;
+ status: CapabilityStatus;
+ config_options: ConfigOptionEntry[];
+ error: string | null;
+};
+
export type PermissionSetting = {
supported: boolean;
default: boolean;
diff --git a/apps/web/src/locales/en/agents.json b/apps/web/src/locales/en/agents.json
index 33f55e3ac2..11f44adb2a 100644
--- a/apps/web/src/locales/en/agents.json
+++ b/apps/web/src/locales/en/agents.json
@@ -94,6 +94,10 @@
"failedToCreateAgent": "Failed to create agent",
"failedToDeleteProfile": "Failed to delete profile",
"failedToFetchCapabilities": "Failed to fetch capabilities",
+ "modelOptionsAuthenticationRequired": "Sign in to this agent to load model options.",
+ "modelOptionsResolutionFailed": "Model options could not be loaded.",
+ "resolvingModelOptions": "Loading model options…",
+ "retryModelOptions": "Retry",
"failedToLoadCommandPreview": "Failed to load command preview",
"failedToLoadMcpConfig": "Failed to load MCP config",
"failedToRefresh": "Failed to refresh: {{error}}",
diff --git a/apps/web/src/locales/pseudo/agents.json b/apps/web/src/locales/pseudo/agents.json
index 6dfce12107..f4ad4daab9 100644
--- a/apps/web/src/locales/pseudo/agents.json
+++ b/apps/web/src/locales/pseudo/agents.json
@@ -94,6 +94,10 @@
"failedToCreateAgent": "Ƒàĩĺēď ţō ćŕēàţē àĝēńţ",
"failedToDeleteProfile": "Ƒàĩĺēď ţō ďēĺēţē ƥŕōƒĩĺē",
"failedToFetchCapabilities": "Ƒàĩĺēď ţō ƒēţćĥ ćàƥàƀĩĺĩţĩēś",
+ "modelOptionsAuthenticationRequired": "Śĩĝń ĩń ţō ţĥĩś àĝēńţ ţō ĺōàď ḿōďēĺ ōƥţĩōńś.",
+ "modelOptionsResolutionFailed": "Ḿōďēĺ ōƥţĩōńś ćōũĺď ńōţ ƀē ĺōàďēď.",
+ "resolvingModelOptions": "Ľōàďĩńĝ ḿōďēĺ ōƥţĩōńś…",
+ "retryModelOptions": "Ŕēţŕŷ",
"failedToLoadCommandPreview": "Ƒàĩĺēď ţō ĺōàď ćōḿḿàńď ƥŕēvĩēŵ",
"failedToLoadMcpConfig": "Ƒàĩĺēď ţō ĺōàď ḾĆƤ ćōńƒĩĝ",
"failedToRefresh": "Ƒàĩĺēď ţō ŕēƒŕēśĥ: {{error}}",
diff --git a/apps/web/src/locales/pt-pt/agents.json b/apps/web/src/locales/pt-pt/agents.json
index 7261e3a83d..ca310fe513 100644
--- a/apps/web/src/locales/pt-pt/agents.json
+++ b/apps/web/src/locales/pt-pt/agents.json
@@ -94,6 +94,10 @@
"failedToCreateAgent": "Não foi possível criar o agente",
"failedToDeleteProfile": "Não foi possível eliminar o perfil",
"failedToFetchCapabilities": "Não foi possível obter as capacidades",
+ "modelOptionsAuthenticationRequired": "Inicie sessão neste agente para carregar as opções do modelo.",
+ "modelOptionsResolutionFailed": "Não foi possível carregar as opções do modelo.",
+ "resolvingModelOptions": "A carregar as opções do modelo…",
+ "retryModelOptions": "Tentar novamente",
"failedToLoadCommandPreview": "Não foi possível carregar a pré-visualização do comando",
"failedToLoadMcpConfig": "Não foi possível carregar a configuração MCP",
"failedToRefresh": "Não foi possível atualizar: {{error}}",
diff --git a/apps/web/src/locales/zh-cn/agents.json b/apps/web/src/locales/zh-cn/agents.json
index 8a8f203210..046dd0e561 100644
--- a/apps/web/src/locales/zh-cn/agents.json
+++ b/apps/web/src/locales/zh-cn/agents.json
@@ -94,6 +94,10 @@
"failedToCreateAgent": "创建代理失败",
"failedToDeleteProfile": "删除配置失败",
"failedToFetchCapabilities": "获取能力失败",
+ "modelOptionsAuthenticationRequired": "请登录此代理以加载模型选项。",
+ "modelOptionsResolutionFailed": "无法加载模型选项。",
+ "resolvingModelOptions": "正在加载模型选项…",
+ "retryModelOptions": "重试",
"failedToLoadCommandPreview": "加载命令预览失败",
"failedToLoadMcpConfig": "加载 MCP 配置失败",
"failedToRefresh": "刷新失败:{{error}}",
diff --git a/docs/decisions/2026-08-07-model-aware-provider-capability-resolution.md b/docs/decisions/2026-08-07-model-aware-provider-capability-resolution.md
new file mode 100644
index 0000000000..cae88b354a
--- /dev/null
+++ b/docs/decisions/2026-08-07-model-aware-provider-capability-resolution.md
@@ -0,0 +1,106 @@
+---
+status: accepted
+date: 2026-08-07
+area: backend, frontend, protocol
+---
+
+# Model-aware provider capability resolution
+
+## Context
+
+ACP providers can change their selectable configuration options after a model
+changes. OpenCode is an example: the live task selector receives a
+`reasoning_effort` option for some selected models, while the settings pages
+currently read one agent-level capability snapshot taken before a model is
+selected. The workflow session override editor and the OpenCode agent profile
+editor therefore cannot show model-dependent options. The shared selector is
+already generic; the missing capability is a model-aware source of truth.
+
+The same problem will occur for future providers and for future option types,
+so provider names and option identifiers must not be embedded in the settings
+UI.
+
+## Decision
+
+Add a sessionless, model-aware capability resolver behind the existing host
+utility ACP boundary.
+
+- Keep the existing agent-level capability discovery as the fast baseline for
+ agent status, model lists, modes, commands, and options available for the
+ provider's default session.
+- When a settings surface selects a model, resolve that model in a short-lived
+ ACP probe session. Apply the model through the generic ACP/session-model
+ machinery, capture the provider's complete resulting config-option snapshot,
+ and normalize it through the shared `internal/agentctl/acpcompat` boundary.
+- Treat the provider's returned snapshot as authoritative for that resolution
+ context. The frontend renders arbitrary ACP `select` options and choices; it
+ does not map OpenCode, Grok, or any other provider by name.
+- Expose resolution through an explicit HTTP endpoint with a request context
+ containing the model and future-dependent inputs such as mode and selected
+ config options. Keep the existing baseline models endpoint unchanged.
+- Cache successful and unsupported resolutions in memory for a short bounded
+ TTL, keyed by agent type and the canonical resolution context. Deduplicate
+ concurrent requests and invalidate the cache when the provider capability
+ baseline is refreshed. Do not persist probe results in the database.
+- A resolution response replaces the current option snapshot for the selected
+ context. If resolution fails, settings retain the existing draft/persisted
+ values and do not invent new controls; the user can retry or save unchanged
+ data.
+
+The initial settings implementation uses the selected model as the only
+context input. The API and cache key are shaped so that mode and selected
+options can become dependencies without another provider-specific design.
+
+## Consequences
+
+Positive consequences:
+
+- Chat, workflow settings, and agent profile settings can share one provider
+ capability contract.
+- A provider can add or remove options dynamically without a frontend release.
+- The expensive part of discovery is bounded by caching and single-flight
+ deduplication.
+- Provider compatibility quirks remain in the ACP compatibility package rather
+ than leaking into React components.
+
+Costs and risks:
+
+- Selecting a model in settings may require a short asynchronous probe before
+ dependent controls appear.
+- A provider that cannot return a complete post-model snapshot will show a
+ degraded discovery state rather than being guessed at by the UI.
+- The backend must carefully bound probe time and process resources because a
+ settings page can request several models in succession.
+- A model change may make previously selected option identifiers invalid; the
+ editable draft must reconcile them only after a successful authoritative
+ snapshot, while preserving persisted data when discovery fails.
+
+## Alternatives considered
+
+### Keep one static capability snapshot per agent
+
+This is simple and preserves the current API, but it cannot represent options
+whose choices depend on the selected model. It was the behavior observed on
+both settings surfaces, so it is insufficient.
+
+### Probe every model during initial discovery
+
+This would make the UI immediately complete, but multiplies provider process
+startup and ACP calls by the number of models, delays the baseline response,
+and still needs a context model for mode- or option-dependent capabilities.
+
+### Hard-code provider-specific option maps in the frontend
+
+This would make OpenCode work quickly but duplicates provider behavior, goes
+stale as providers change, and violates the generic ACP capability boundary.
+
+### Reuse the active task session for settings discovery
+
+Settings pages may be opened without a task, and mutating an active task to
+discover options would create user-visible side effects. A sessionless probe
+keeps discovery isolated.
+
+## Follow-up
+
+The behavioral contract and implementation work are specified in
+[`Dynamic Provider Model Options`](../specs/agents/dynamic-provider-options.md).
diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md
index 2bc40f6f07..37f67bca54 100644
--- a/docs/decisions/INDEX.md
+++ b/docs/decisions/INDEX.md
@@ -128,4 +128,5 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-08-05-server-owned-quick-terminal-descriptors | [Server-Owned Quick Terminal Descriptors](2026-08-05-server-owned-quick-terminal-descriptors.md) | accepted | backend, frontend, protocol, security | 2026-08-05 |
| 2026-08-05-nested-submodules-as-repository-scopes | [Model Nested Submodules as Repository Scopes](2026-08-05-nested-submodules-as-repository-scopes.md) | accepted | backend, frontend, protocol | 2026-08-05 |
| 2026-08-05-homebrew-remote-helper-audit | [Preserve Remote Helpers in Homebrew Installs](2026-08-05-homebrew-remote-helper-audit.md) | accepted | infra, workflow | 2026-08-05 |
+| 2026-08-07-model-aware-provider-capability-resolution | [Model-aware provider capability resolution](2026-08-07-model-aware-provider-capability-resolution.md) | accepted | backend, frontend, protocol | 2026-08-07 |
| 2026-08-07-claude-allowlist-label-bridge | [Use the Claude Allowlist as a Trusted Preview Gate](2026-08-07-claude-allowlist-label-bridge.md) | accepted | infra, workflow, security | 2026-08-07 |
diff --git a/docs/plans/dynamic-provider-options/plan.md b/docs/plans/dynamic-provider-options/plan.md
new file mode 100644
index 0000000000..9ddbe2cce0
--- /dev/null
+++ b/docs/plans/dynamic-provider-options/plan.md
@@ -0,0 +1,315 @@
+---
+spec: docs/specs/agents/dynamic-provider-options.md
+decision: docs/decisions/2026-08-07-model-aware-provider-capability-resolution.md
+created: 2026-08-07
+status: complete
+---
+
+# Implementation Plan: Dynamic Provider Model Options
+
+## Overview
+
+Add a model-aware, provider-neutral capability resolver for settings surfaces
+that do not have a live ACP session. The resolver will use the existing host
+utility agentctl process, apply a requested model through generic ACP session
+model logic, and return the provider's complete normalized configuration-option
+snapshot. Workflow session overrides and agent profile settings will consume
+the same frontend hook and reconcile their draft option maps against the
+returned snapshot.
+
+The existing baseline agent capability endpoint remains responsible for the
+model list, agent status, modes, commands, and default-session options. The
+live chat selector remains session-authoritative. No database migration or
+provider-specific frontend mapping is planned.
+
+## Architecture
+
+### Baseline versus resolved capabilities
+
+`AvailableAgent.model_config` remains a fast baseline. It is used to render the
+model picker immediately and to preserve a usable selector while a dependent
+resolution is loading. It must not be treated as the final option set for a
+selected model.
+
+The new resolver accepts an agent name and a canonical context containing the
+selected model, with optional mode and selected config options. It starts or
+uses the existing host utility ACP boundary, performs a model change in the
+probe session, waits for the provider's complete response or bounded config
+update notification, and normalizes the result through
+`internal/agentctl/acpcompat`. The returned option list is a full replacement
+for the current context, including an intentional empty list.
+
+The provider response is authoritative. The UI renders arbitrary ACP select
+options and choices through `ModelConfigSelector`; it does not know that an
+option came from OpenCode or that it is called reasoning effort.
+
+### Resolution API and cache
+
+Add `POST /api/v1/agent-models/:agentName/resolve`. The existing GET endpoint
+continues to return the baseline dynamic model response. The POST body has a
+required `model` and optional `mode`, `config_options`, and `refresh` fields.
+The response returns the agent name, requested model, status, complete
+normalized `config_options`, and a sanitized error when resolution is not
+authoritative.
+
+Host utility resolution is cached in memory by agent type plus canonicalized
+context. Use a bounded TTL (five minutes unless existing runtime limits require
+a shorter value), single-flight deduplication for identical concurrent keys,
+and invalidation on baseline refresh. Cache failures only when they represent a
+stable unsupported result; transient process/timeout failures should be
+retryable and must not poison the cache indefinitely. The cache is runtime-only
+and is safe to lose on restart.
+
+### Settings behavior
+
+The workflow rule card and profile form request resolution whenever their
+selected model/context changes. The hook must provide baseline options,
+resolved options, loading, error, and retry state while protecting against
+out-of-order responses. A successful snapshot replaces, rather than merges
+with, the previous option list. The editable `config_options` map drops keys
+not present in a successful snapshot before the next save. A failed resolution
+does not rewrite persisted data or silently remove saved values.
+
+The shared selector stays generic. If a small loading/error affordance is
+needed, add it as an optional status prop with localized copy. The chat path is
+not changed because its live session already emits authoritative updates.
+
+### Probe compatibility
+
+The utility probe must use the same client capability metadata and ACP
+compatibility normalization as live sessions. The model application path must
+support typed config-option model changes and legacy model methods through the
+existing `sessionmodel` abstraction. Provider-specific quirks remain in
+`internal/agentctl/acpcompat`; no OpenCode branch belongs in settings or the
+host utility cache.
+
+### Mock provider contract
+
+Make the mock ACP agent model-dependent for E2E. Its initial session should
+advertise a small baseline, and applying one model should return no extra
+options while applying another should return an effort-like select option.
+This keeps tests provider-neutral while proving that settings resolve after a
+model change instead of accidentally relying on static `AvailableAgent`
+data.
+
+## Backend
+
+### Model-aware utility probe
+
+Extend the utility probe request/result and ACP executor flow so a probe can
+carry a model-resolution context. After `session/new`, apply the requested
+model, capture the complete response config or a bounded configuration-update
+notification, and pass it through the existing config-option conversion and
+ACP dialect normalization helpers. Keep baseline probing behavior unchanged
+when no model context is supplied.
+
+Add focused coverage for response snapshots, notification fallback, unsupported
+model application, provider normalization, and the existing client capability
+metadata. Reuse `sessionmodel.ApplySDKWithConfigOptions` where its returned
+config is authoritative instead of duplicating ACP request logic.
+
+### Host utility resolution service
+
+Add typed resolution inputs/results to the host utility package, a separate
+runtime cache, canonical cache-key construction, TTL handling, single-flight
+deduplication, refresh invalidation, and a bounded context timeout. Expose a
+narrow public manager method for the settings controller. Do not persist
+resolved capabilities or attach them to a task session.
+
+### Settings HTTP contract
+
+Add DTOs for the resolver request and response, controller validation, and the
+POST route beside the existing dynamic-model route. Reuse the existing agent
+availability and authorization checks and sanitize probe failures consistently
+with the current settings APIs. Preserve the baseline GET response for existing
+callers.
+
+## Frontend
+
+### API and hook
+
+Extend `settings-api.ts` and `http-agents.ts` with the resolver request/response
+contract. Extend `use-dynamic-models.ts` with a reusable model-resolution hook
+or resolver returned by the existing hook. It must:
+
+- key requests by agent, model, mode, and canonical option context;
+- share in-flight and cached results across workflow/profile consumers;
+- ignore stale responses after a newer model selection;
+- replace the option snapshot atomically;
+- reconcile draft option maps only after a successful response; and
+- keep persisted values readable and retryable when discovery fails.
+
+Add pure unit coverage for canonical context, reconciliation, stale responses,
+cache reuse, refresh, and error recovery.
+
+### Workflow editor
+
+Wire `WorkflowSessionConfigRuleCard` to resolve options for the rule's selected
+model. Keep the current static model list and rule serialization. On a
+successful model-dependent response, render every returned select option and
+save only the reconciled map. Preserve existing read-only and existing-rule
+failure behavior.
+
+### Agent profile editor
+
+Wire `ProfileFormFields` to the same resolver using the profile's current
+model. Keep model/mode/command discovery on the existing baseline hook. Ensure
+changing a profile model refreshes dependent options, removes only
+successfully-invalidated draft keys, and saves arbitrary resolved options.
+
+All new loading, retry, and failure text must use i18next keys and be added to
+the affected locale catalogs. Keep controls within the existing mobile
+one-column layout and touch-target conventions.
+
+## Tests
+
+Backend unit/integration coverage must include:
+
+- utility model application returning complete config snapshots;
+- config-update notification fallback and bounded timeout;
+- shared ACP dialect normalization;
+- host utility cache key, TTL, refresh invalidation, and single-flight behavior;
+- controller request validation and sanitized errors; and
+- resolver HTTP response shape while the baseline GET contract remains stable.
+
+Frontend unit/component coverage must include:
+
+- resolver API request/response mapping;
+- dynamic option replacement rather than stale merging;
+- model-switch reconciliation and preservation on failure;
+- latest-request-wins behavior;
+- workflow rule rendering and serialization;
+- profile form rendering and save behavior; and
+- generic unknown option identifiers and choices.
+
+## E2E Tests and Documentation
+
+Update the mock ACP provider and existing settings specs so the selected model,
+not the static boot snapshot, is what causes the dependent option to appear.
+Cover both desktop flows:
+
+- workflow session override selects a model and saves a dynamic option;
+- OpenCode/ACP profile selects a model, saves a dynamic option, reloads, and
+ displays it.
+
+Cover the mobile workflow path with touch interaction, one-column layout, and
+no document-level horizontal overflow. Keep the existing managed Playwright
+runner and project split; do not run the mobile spec through desktop Chromium.
+
+Update `docs/public/tasks-and-workflows.md` with the provider-neutral behavior,
+model-dependent option discovery, and failure/retry expectations if the public
+documentation coverage inventory requires the change. Keep the spec and ADR as
+the detailed contract.
+
+## Waves
+
+Wave 1:
+
+- [x] [task-01-model-aware-probe](task-01-model-aware-probe.md)
+
+Wave 2 (depends on the probe contract):
+
+- [x] [task-02-resolution-cache-api](task-02-resolution-cache-api.md)
+
+Wave 3 (depends on the HTTP contract):
+
+- [x] [task-03-resolution-hook](task-03-resolution-hook.md)
+
+Wave 4 (depends on the hook):
+
+- [x] [task-04-settings-integration](task-04-settings-integration.md)
+
+Wave 5 (depends on the settings behavior):
+
+- [x] [task-05-e2e-docs](task-05-e2e-docs.md)
+
+The dependency chain is complete. The implementation uses a model-only request
+from the current settings consumers, while the backend keeps the optional mode
+and option context available for providers that need it later. Save
+contributors remain blocked until a selected model has an authoritative
+resolution and any stale draft options have been reconciled. Failed discovery
+keeps the draft readable and exposes a localized retry action.
+
+## Verification
+
+Targeted backend checks:
+
+```bash
+cd apps/backend && go test ./internal/agentctl/server/utility ./internal/agentctl/acpcompat ./internal/agent/hostutility ./internal/agent/settings/controller ./internal/agent/settings/handlers
+```
+
+Targeted frontend checks:
+
+```bash
+cd apps && pnpm --filter @kandev/web exec vitest run hooks/domains/settings/use-dynamic-models.test.ts components/settings/profile-form-fields.test.tsx components/settings/workflow-session-config-editor.test.tsx components/settings/use-workflow-draft-contributor.test.ts components/settings/model-config-resolution-status.test.tsx components/settings/profile-model-config.test.ts components/model-config-selector.test.tsx --reporter=dot
+cd apps/web && pnpm run typecheck
+cd apps/web && pnpm run i18n:check
+cd apps/web && pnpm run i18n:ratchet
+```
+
+Managed desktop and mobile E2E checks:
+
+```bash
+cd apps/web && pnpm e2e:run --host --project chromium tests/settings/agent-profile-acp.spec.ts tests/workflow/workflow-settings.spec.ts
+cd apps/web && pnpm e2e:run --host --project mobile-chrome tests/workflow/mobile-workflow-settings.spec.ts
+```
+
+Public docs checks when the public page changes:
+
+```bash
+node --test scripts/validate-public-docs.test.mjs
+node scripts/validate-public-docs.mjs
+```
+
+Verification Results:
+
+- Backend build passed; focused backend packages: 307 tests passed across five
+ packages.
+- Frontend focused unit tests: 42 tests passed across the seven listed files;
+ typecheck, ESLint, i18n checks, and i18n ratchet passed.
+- Managed E2E passed: 19 Chromium desktop tests and 3 mobile Chromium tests
+ across profile and workflow settings. The tests also cover startup capability
+ revalidation, so provider registration order and probe timing are not test
+ assumptions.
+- Public documentation checks were not required because this change updates
+ the implementation plan and workflow spec, not `docs/public/**`.
+
+## Review follow-up
+
+The implementation completed the following review work:
+
+- [x] Make the current settings resolver model-only. Do not send editable
+ option values or mode as context from the profile and workflow consumers.
+- [x] Remove probe mutations that are applied after the returned snapshot, or
+ implement and test final-snapshot capture for a future multi-input contract.
+- [x] Show localized resolution loading/failure state and provide retry in both
+ settings surfaces.
+- [x] Test controller error sanitization and authentication status mapping.
+- [x] Reconcile only after a user model/context change so opening a saved
+ profile does not create unsaved changes.
+- [x] Complete the recommended cache, context-cancellation, conversion-helper,
+ wrapper, mock-agent, and missing probe/host-utility test cleanup.
+- [x] Remove E2E force-click workarounds after the selector no longer remounts
+ for each option edit, then rerun desktop and mobile E2E.
+- [x] Block profile and workflow saves during model-option reconciliation,
+ require a snapshot after the final ACP option mutation, isolate forced
+ refresh generations, use typed missing-agent errors, and keep zero-baseline
+ resolution status visible.
+- [x] Mark ACP inference agents as dynamic before their first cache snapshot,
+ poll pending capability probes with a bounded retry, and make settings E2E
+ fixtures independent of provider registration order.
+
+## Risks and open questions
+
+- Some ACP providers may advertise model changes only through delayed
+ notifications. The probe needs a bounded wait that is long enough for normal
+ providers but cannot hold a settings request indefinitely.
+- Some providers may require a mode or prior option before returning the final
+ snapshot. The API accepts those inputs now, but the first UI should only send
+ values it can prove are current.
+- Model resolution can be expensive for large model lists. The TTL,
+ single-flight cache, and refresh semantics should be measured with the mock
+ provider before broadening the UI to prefetch.
+- If a provider cannot produce a complete post-model snapshot, the UI must
+ remain safe and understandable without implying that the baseline is
+ authoritative for the selected model.
diff --git a/docs/plans/dynamic-provider-options/task-01-model-aware-probe.md b/docs/plans/dynamic-provider-options/task-01-model-aware-probe.md
new file mode 100644
index 0000000000..e5a75798ef
--- /dev/null
+++ b/docs/plans/dynamic-provider-options/task-01-model-aware-probe.md
@@ -0,0 +1,58 @@
+---
+id: dynamic-provider-options-01
+title: Add model-aware capability probe
+status: done
+wave: 1
+depends_on: []
+plan: docs/plans/dynamic-provider-options/plan.md
+spec: docs/specs/agents/dynamic-provider-options.md
+---
+
+# Add model-aware capability probe
+
+## Scope
+
+Extend the sessionless ACP utility probe so it can apply a requested model and
+return the provider's complete normalized configuration-option snapshot. Keep
+the existing no-context probe behavior unchanged.
+
+## Acceptance conditions
+
+1. A model-aware probe applies the requested model through the generic ACP
+ session-model path and returns the full post-model config-option snapshot.
+2. A provider that reports the snapshot through a config-update notification is
+ handled within a bounded wait; unsupported or incomplete responses are
+ reported without being presented as authoritative.
+3. Utility tests cover response snapshots, notification fallback, model
+ application failure, and ACP compatibility normalization.
+
+## Files
+
+- `apps/backend/internal/agentctl/server/utility/types.go`
+- `apps/backend/internal/agentctl/server/utility/acp_executor.go`
+- `apps/backend/internal/agentctl/server/utility/model_apply_test.go`
+- `apps/backend/internal/agentctl/server/utility/acp_probe_capabilities_test.go`
+- `apps/backend/internal/agentctl/server/utility/acp_executor_test.go`
+- `apps/backend/internal/agentctl/acpcompat/` (only the shared normalization
+ helper files that need extraction or reuse)
+
+## Dependencies and inputs
+
+- Existing ACP probe/session initialization and `sessionmodel` APIs.
+- Existing `ClientCapabilityMeta` and ACP dialect normalization.
+- The dynamic-provider-options spec and ADR.
+
+## Output contract
+
+Expose a utility-level model-resolution request/result that the host utility
+manager can call without knowing provider-specific option identifiers. The
+result distinguishes a complete empty snapshot from an error or incomplete
+provider response.
+
+## Checks
+
+```bash
+cd apps/backend && go test ./internal/agentctl/server/utility ./internal/agentctl/acpcompat
+```
+
+Results: `cd apps/backend && go test ./internal/agentctl/server/utility -count=1` — passed.
diff --git a/docs/plans/dynamic-provider-options/task-02-resolution-cache-api.md b/docs/plans/dynamic-provider-options/task-02-resolution-cache-api.md
new file mode 100644
index 0000000000..7f688c6c98
--- /dev/null
+++ b/docs/plans/dynamic-provider-options/task-02-resolution-cache-api.md
@@ -0,0 +1,58 @@
+---
+id: dynamic-provider-options-02
+title: Add resolution cache API
+status: done
+wave: 2
+depends_on:
+ - dynamic-provider-options-01
+plan: docs/plans/dynamic-provider-options/plan.md
+spec: docs/specs/agents/dynamic-provider-options.md
+---
+
+# Add resolution cache API
+
+## Scope
+
+Expose the model-aware probe through host utility resolution, a bounded
+runtime-only cache, and the authenticated agent-settings HTTP endpoint.
+
+## Acceptance conditions
+
+1. Identical agent/context requests share cached or in-flight work, refresh
+ bypasses/invalidate the entry, and the cache cannot persist data to SQLite.
+2. `POST /api/v1/agent-models/:agentName/resolve` validates the request,
+ returns a complete normalized snapshot on success, and sanitizes failures.
+3. Existing `GET /api/v1/agent-models/:agentName` behavior remains compatible.
+
+## Files
+
+- `apps/backend/internal/agent/hostutility/types.go`
+- `apps/backend/internal/agent/hostutility/cache.go`
+- `apps/backend/internal/agent/hostutility/manager.go`
+- `apps/backend/internal/agent/hostutility/public.go`
+- `apps/backend/internal/agent/hostutility/manager_test.go`
+- `apps/backend/internal/agent/settings/dto/dto.go`
+- `apps/backend/internal/agent/settings/controller/agent_config.go`
+- `apps/backend/internal/agent/settings/controller/agent_config_test.go`
+- `apps/backend/internal/agent/settings/handlers/handlers.go`
+- `apps/backend/internal/agent/settings/handlers/handlers_test.go`
+
+## Dependencies and inputs
+
+- `dynamic-provider-options-01` utility request/result.
+- Existing host utility availability, refresh, authorization, and settings
+ route conventions.
+
+## Output contract
+
+Provide a typed controller method and HTTP route matching the spec's request
+context and response shape. Cache key construction must be deterministic for
+maps, include future context fields, and have explicit TTL/invalidation tests.
+
+## Checks
+
+```bash
+cd apps/backend && go test ./internal/agent/hostutility ./internal/agent/settings/controller ./internal/agent/settings/handlers
+```
+
+Results: `cd apps/backend && go test ./internal/agentctl/server/utility ./internal/agent/hostutility ./internal/agent/settings/controller ./internal/agent/settings/handlers -count=1` — passed.
diff --git a/docs/plans/dynamic-provider-options/task-03-resolution-hook.md b/docs/plans/dynamic-provider-options/task-03-resolution-hook.md
new file mode 100644
index 0000000000..bc861c6ca8
--- /dev/null
+++ b/docs/plans/dynamic-provider-options/task-03-resolution-hook.md
@@ -0,0 +1,58 @@
+---
+id: dynamic-provider-options-03
+title: Add model resolution hook
+status: done
+wave: 3
+depends_on:
+ - dynamic-provider-options-02
+plan: docs/plans/dynamic-provider-options/plan.md
+spec: docs/specs/agents/dynamic-provider-options.md
+---
+
+# Add model resolution hook
+
+## Scope
+
+Add the frontend API/types and a reusable model-aware resolution hook that
+shares requests, replaces option snapshots atomically, reconciles drafts only
+after success, and ignores stale responses.
+
+## Acceptance conditions
+
+1. The hook sends the selected model/context to the resolver endpoint and
+ exposes loading, resolved options, sanitized error, retry, and refresh
+ state.
+2. A newer model selection prevents an older response from changing the
+ current options; a successful snapshot never merges stale option IDs.
+3. Resolver failure preserves the existing draft/persisted values and does not
+ create unverified option controls.
+
+## Files
+
+- `apps/web/lib/api/domains/settings-api.ts`
+- `apps/web/lib/types/http-agents.ts`
+- `apps/web/hooks/domains/settings/use-dynamic-models.ts`
+- `apps/web/hooks/domains/settings/use-dynamic-models.test.ts`
+- `apps/web/lib/agent-config/` (only if a pure reconciliation helper is
+ extracted; add its focused test beside it)
+
+## Dependencies and inputs
+
+- `dynamic-provider-options-02` HTTP contract.
+- Existing `ModelConfig`, `ConfigOptionEntry`, `fetchDynamicModels`, and
+ settings API error conventions.
+
+## Output contract
+
+Return a provider-neutral hook/helper consumable by both profile and workflow
+settings. It must accept a stable agent/model/context identity and make the
+complete option snapshot plus reconciliation behavior available without
+duplicating fetch logic in either component.
+
+## Checks
+
+```bash
+cd apps && pnpm --filter @kandev/web exec vitest run hooks/domains/settings/use-dynamic-models.test.ts --reporter=dot
+```
+
+Results: `cd apps && pnpm --filter @kandev/web exec vitest run hooks/domains/settings/use-dynamic-models.test.ts components/settings/profile-model-config.test.ts` — passed.
diff --git a/docs/plans/dynamic-provider-options/task-04-settings-integration.md b/docs/plans/dynamic-provider-options/task-04-settings-integration.md
new file mode 100644
index 0000000000..45832300b7
--- /dev/null
+++ b/docs/plans/dynamic-provider-options/task-04-settings-integration.md
@@ -0,0 +1,63 @@
+---
+id: dynamic-provider-options-04
+title: Integrate settings selectors
+status: done
+wave: 4
+depends_on:
+ - dynamic-provider-options-03
+plan: docs/plans/dynamic-provider-options/plan.md
+spec: docs/specs/agents/dynamic-provider-options.md
+---
+
+# Integrate settings selectors
+
+## Scope
+
+Wire the shared model-resolution behavior into workflow session rules and ACP
+agent profile settings while preserving existing persistence, read-only, and
+generic selector behavior.
+
+## Acceptance conditions
+
+1. Workflow rules display and save arbitrary provider-returned options after a
+ model change, including replacement/removal of options that the new model
+ does not advertise.
+2. ACP agent profile settings use the same resolver and save/reload the same
+ dynamic options without provider-specific branches.
+3. Loading, retry, and error states are localized; mobile layouts retain
+ touch-sized controls and no horizontal overflow.
+
+## Files
+
+- `apps/web/components/settings/workflow-session-config-rule-card.tsx`
+- `apps/web/components/settings/workflow-session-config-shared.ts`
+- `apps/web/components/settings/workflow-session-config-editor.test.tsx`
+- `apps/web/components/settings/profile-form-fields.tsx`
+- `apps/web/components/settings/profile-form-fields.test.tsx`
+- `apps/web/components/model-config-selector.tsx` (only if an optional
+ resolver status affordance is required)
+- `apps/web/public/locales/` affected locale catalogs
+
+## Dependencies and inputs
+
+- `dynamic-provider-options-03` hook and reconciliation contract.
+- Existing workflow rule serializer, profile save coordinator, and
+ `ModelConfigSelector` select-option behavior.
+- Mobile-parity requirements for the existing settings layouts.
+
+## Output contract
+
+Both settings surfaces consume the same provider-neutral resolved snapshot.
+Persisted config maps are not mutated by a failed discovery request; successful
+model changes save only values valid for the returned snapshot.
+
+## Checks
+
+```bash
+cd apps && pnpm --filter @kandev/web exec vitest run components/settings/profile-form-fields.test.tsx components/settings/workflow-session-config-editor.test.tsx components/model-config-selector.test.tsx --reporter=dot
+cd apps/web && pnpm run typecheck
+cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet
+```
+
+Results: profile/workflow component tests, frontend typecheck, ESLint, i18n
+check, and i18n ratchet — passed.
diff --git a/docs/plans/dynamic-provider-options/task-05-e2e-docs.md b/docs/plans/dynamic-provider-options/task-05-e2e-docs.md
new file mode 100644
index 0000000000..17e5175648
--- /dev/null
+++ b/docs/plans/dynamic-provider-options/task-05-e2e-docs.md
@@ -0,0 +1,62 @@
+---
+id: dynamic-provider-options-05
+title: Verify dynamic option flows
+status: done
+wave: 5
+depends_on:
+ - dynamic-provider-options-04
+plan: docs/plans/dynamic-provider-options/plan.md
+spec: docs/specs/agents/dynamic-provider-options.md
+---
+
+# Verify dynamic option flows
+
+## Scope
+
+Make the mock ACP provider expose model-dependent options, update desktop and
+mobile Playwright coverage, and keep public workflow documentation aligned.
+
+## Acceptance conditions
+
+1. The mock provider's dependent option appears only after the selected model is
+ resolved, and desktop workflow/profile tests save and reload that option.
+2. Mobile workflow coverage configures the resolved option through touch,
+ retains the existing one-column layout, and reports no horizontal overflow.
+3. Public documentation and its validators describe the dynamic capability
+ behavior without adding provider-specific UI assumptions.
+
+## Files
+
+- `apps/backend/cmd/mock-agent/main.go`
+- `apps/web/e2e/tests/settings/agent-profile-acp.spec.ts`
+- `apps/web/e2e/tests/workflow/workflow-settings.spec.ts`
+- `apps/web/e2e/tests/workflow/mobile-workflow-settings.spec.ts`
+- `apps/web/e2e/pages/workflow-settings-page.ts` (only if the page object needs
+ a dynamic-option helper)
+- `docs/public/tasks-and-workflows.md`
+- `docs/public/agents-and-profiles.md`
+
+## Dependencies and inputs
+
+- `dynamic-provider-options-04` settings integration.
+- Existing mock-agent fixture contract and managed Playwright projects.
+- Public docs validator and documentation coverage inventory.
+
+## Output contract
+
+End-to-end evidence must exercise a model-dependent provider response rather
+than a static capability option. Keep the desktop and mobile specs in their
+existing project paths.
+
+## Checks
+
+```bash
+cd apps/web && pnpm e2e:run --project chromium tests/settings/agent-profile-acp.spec.ts tests/workflow/workflow-settings.spec.ts
+cd apps/web && pnpm e2e:run --project mobile-chrome tests/workflow/mobile-workflow-settings.spec.ts
+node --test scripts/validate-public-docs.test.mjs
+node scripts/validate-public-docs.mjs
+```
+
+Results: managed desktop and mobile profile/workflow model-option flows —
+passed. `node --test scripts/validate-public-docs.test.mjs` and
+`node scripts/validate-public-docs.mjs` — passed.
diff --git a/docs/public/agents-and-profiles.md b/docs/public/agents-and-profiles.md
index e7c7202cfe..2fd1f4db3a 100644
--- a/docs/public/agents-and-profiles.md
+++ b/docs/public/agents-and-profiles.md
@@ -80,6 +80,12 @@ Select an agent, create a profile, then open **Settings > Agents > _Agent_ > _Pr
Model, mode, command, and configuration choices are probed from the locally installed CLI and cached. The managed **Update agent** action refreshes them automatically; after other CLI changes, refresh the profile manually. Probe status can report **auth required**, **not installed**, **not configured**, or **failed**; a saved model name does not prove that the current provider account can use it.
+Configuration options are resolved for the model selected in the profile. An
+agent can therefore show a different option set for each model. If a model
+change makes a saved option value unsupported, Kandev removes that value after
+a successful resolution; a failed resolution keeps the draft unchanged so you
+can retry it.
+
### Monitor capability and subscription status
Use the profile refresh control after installing, authenticating, or upgrading an agent. A manual refresh updates both the advertised models, modes, and commands and the visible capability status, so an old failure banner does not remain authoritative after the local CLI recovers.
diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md
index 2541d480fa..228e5a52db 100644
--- a/docs/public/tasks-and-workflows.md
+++ b/docs/public/tasks-and-workflows.md
@@ -315,6 +315,12 @@ data later becomes unavailable. The editor uses the same model and ACP option
picker as the chat input, so provider-specific models and options are selected
from the agent's advertised capabilities.
+The model and option list is resolved for the selected model. Providers can
+therefore expose different options for different models, and the list can
+change after a model selection. Kandev removes saved option values only after
+a successful provider response; if discovery fails, the current draft remains
+available and can be retried.
+
Each rule can **Set** a model and any selected options, **Keep** the settings
already active, or **Restore original** to reapply the immutable model and
option values captured when the original session finished initializing, after
diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md
index 9fb7086a5c..215bfaccf4 100644
--- a/docs/specs/INDEX.md
+++ b/docs/specs/INDEX.md
@@ -101,6 +101,7 @@ Roles, governance gates, and granular permissions that apply across human users
|---|---|
| [runtime-updates](agents/runtime-updates.md) | approved |
| [profile-disable](agents/profile-disable.md) | draft |
+| [dynamic-provider-options](agents/dynamic-provider-options.md) | shipped |
| [roles](agents/roles.md) | shipped |
| [governance](agents/governance.md) | shipped |
| [granular-permissions](agents/granular-permissions.md) | draft |
diff --git a/docs/specs/agents/dynamic-provider-options.md b/docs/specs/agents/dynamic-provider-options.md
new file mode 100644
index 0000000000..8faa5a11ad
--- /dev/null
+++ b/docs/specs/agents/dynamic-provider-options.md
@@ -0,0 +1,258 @@
+---
+status: shipped
+created: 2026-08-07
+owner: kandev
+---
+
+# Dynamic Provider Model Options
+
+## Why
+
+Provider capabilities are not always properties of an agent family alone.
+Some ACP providers change the available configuration options when a model is
+selected. OpenCode currently demonstrates this split: the task chat selector
+receives model-dependent options such as reasoning effort from the live
+session, while the workflow session override editor and the OpenCode agent
+profile editor only have the agent-level capability snapshot. The settings
+surfaces therefore show the model but omit valid dependent options.
+
+Kandev needs one provider-neutral contract that works for OpenCode and future
+providers without maintaining a list of provider-specific option names.
+
+## What
+
+Kandev resolves model-dependent ACP configuration options on demand for the
+settings surfaces that do not have a live task session:
+
+- The existing agent-level capability response remains the baseline for agent
+ availability, model lists, modes, commands, and options available before a
+ model is selected.
+- After a user selects a model in the workflow session override editor or an
+ agent profile editor, Kandev requests a model-aware capability snapshot from
+ the provider through a sessionless ACP probe.
+- The probe applies the requested model using generic ACP session-model logic,
+ captures the complete post-model `config_options` response or update, and
+ applies the existing ACP compatibility normalization.
+- The response is a complete snapshot for that resolution context. It may
+ contain zero, one, or many arbitrary ACP `select` options and their choices.
+ The UI does not recognize provider-specific option identifiers.
+- The settings pages share the resolver and its short-lived in-memory cache.
+ The live chat selector remains session-authoritative and is not changed by
+ this feature.
+- The initial UI sends the model as the dependency context. The contract also
+ accepts mode and currently selected options so providers with additional
+ dependencies can be supported without changing the endpoint shape.
+
+## Scenarios
+
+### Model adds an option in workflow settings
+
+Given an OpenCode agent family whose default model advertises only model and
+mode, when a workflow author selects a model that supports reasoning effort,
+then the workflow rule editor loads and displays the provider-supplied
+reasoning-effort select and its choices. The option appears without an
+OpenCode-specific frontend mapping.
+
+### Model adds an option in an agent profile
+
+Given the OpenCode agent profile settings page, when the profile model changes
+to a model with additional selectable options, then the profile editor uses
+the same resolved snapshot as workflow settings and allows those options to be
+selected and saved.
+
+### Model removes an option
+
+Given a draft with a selected option for the current model, when the author
+selects a model whose authoritative snapshot does not advertise that option,
+then the editor removes that option from the editable configuration before a
+subsequent save. The model remains selected and the provider's current options
+are shown.
+
+### Provider adds arbitrary future options
+
+Given a provider returns a new ACP `select` option with an identifier and
+choices unknown to Kandev, when the model is resolved, then the shared selector
+renders that option using its generic label, value, and choice data. No code
+change is required for the new identifier.
+
+### Resolution fails
+
+Given a model selection whose capability probe times out, is unavailable, or
+returns no complete post-model option snapshot, when the settings page receives
+the failure, then it keeps already persisted and currently selected values,
+does not offer unverified new options, shows a retryable discovery error, and
+allows a model-only save when the rest of the form is valid.
+
+### Responses arrive out of order
+
+Given an author selects model A and then quickly selects model B, when the
+resolution for A completes after the resolution for B, then the editor ignores
+A's result and only displays options for B.
+
+### Shared resolution work
+
+Given workflow and profile settings request the same agent, model, and context
+within the cache TTL, when both pages resolve capabilities, then Kandev
+deduplicates the backend work and returns the same snapshot. A refresh request
+or baseline capability refresh invalidates the cached result.
+
+### Mobile settings
+
+Given a phone viewport, when the author selects a model and configures the
+resolved options in either settings flow, then controls remain in a one-column
+touch-friendly layout, use the existing settings scroll owner, and do not
+create document-level horizontal overflow.
+
+## Data model
+
+No durable database schema is added.
+
+The existing profile and workflow data continue to store the selected model
+and `config_options` map. A successful resolution reconciles the editable map
+against the returned option identifiers before save. A failed resolution does
+not rewrite persisted profile or workflow data.
+
+The runtime-only resolution context is:
+
+```json
+{
+ "agent_name": "opencode-acp",
+ "model": "opencode-go/glm-5.2",
+ "mode": "build",
+ "config_options": {
+ "reasoning_effort": "high"
+ }
+}
+```
+
+The initial frontend sends only `model`; `mode` and `config_options` are
+optional inputs for providers whose capabilities depend on more than the
+model. Cache keys canonicalize map ordering and include every supplied
+context field.
+
+## API surface
+
+The existing baseline endpoint remains unchanged:
+
+`GET /api/v1/agent-models/:agentName`
+
+Add a model-aware resolver endpoint:
+
+`POST /api/v1/agent-models/:agentName/resolve`
+
+Request body:
+
+```json
+{
+ "model": "opencode-go/glm-5.2",
+ "mode": "build",
+ "config_options": {
+ "reasoning_effort": "high"
+ },
+ "refresh": false
+}
+```
+
+`model` is required for the first version. `mode`, `config_options`, and
+`refresh` are optional.
+
+Successful response:
+
+```json
+{
+ "agent_name": "opencode-acp",
+ "model": "opencode-go/glm-5.2",
+ "status": "ok",
+ "config_options": [
+ {
+ "id": "reasoning_effort",
+ "name": "Reasoning effort",
+ "type": "select",
+ "current_value": "high",
+ "options": ["low", "medium", "high", "max"]
+ }
+ ],
+ "error": null
+}
+```
+
+The exact wire field names follow the existing `ConfigOptionEntry` DTO shape;
+the important contract is that `config_options` is a complete normalized
+snapshot, not a delta. Errors use the existing agent-settings error/status
+conventions and never expose provider secrets or raw process output.
+
+## State machine
+
+Each settings surface follows this state machine for the selected resolution
+context:
+
+| State | Event | Result |
+| --- | --- | --- |
+| baseline | model selected | Show baseline options and start one resolver request. |
+| resolving | matching response | Replace options with the complete snapshot and reconcile the draft. |
+| resolving | stale response | Ignore it and keep the newer model's state. |
+| resolving | timeout/error | Keep existing values, show retryable error, and do not add controls. |
+| resolved | same context requested | Reuse the runtime cache. |
+| resolved | refresh or baseline invalidation | Request a fresh snapshot. |
+
+An option snapshot is replaced atomically. The frontend must not merge an old
+model's choices with a new model's choices.
+
+## Permissions
+
+The resolver uses the same authenticated agent-settings route and workspace
+authorization as the existing dynamic-model endpoint. It does not create a
+task session, change an agent profile, or mutate provider configuration.
+
+## Failure modes
+
+- If the agent is unavailable, the API returns the existing unavailable/error
+ status and the UI keeps the baseline or last-known values.
+- If the probe cannot apply the requested model, no partial option snapshot is
+ returned as authoritative.
+- If the provider applies the model but supplies neither a complete response
+ nor a bounded configuration-update notification, the result is degraded and
+ the UI does not guess at model-specific options.
+- Probe execution has a bounded timeout. Concurrent identical requests share
+ one in-flight resolution; distinct contexts do not block indefinitely behind
+ one another.
+- A stale browser response cannot overwrite a newer model selection.
+- If a previously saved option is no longer advertised after a successful
+ resolution, it is removed from the next editable save for that model. If
+ discovery fails, it remains in persisted data and is not silently deleted.
+- Provider errors are sanitized before reaching the HTTP response or UI.
+
+## Persistence guarantees
+
+- No resolved capability snapshot is persisted in SQLite or workflow JSON.
+- Existing profile and workflow `config_options` persistence remains the source
+ of truth for user selections.
+- Runtime cache entries may disappear on process restart without affecting
+ saved settings.
+- A successful settings save stores the selected model and the reconciled
+ option map. A failed discovery never rewrites saved data by itself.
+
+## Out of scope
+
+- Hard-coding OpenCode, Grok, or any provider's option names in React code.
+- Changing the live task chat selector's session event protocol.
+- Resolving every model eagerly during initial agent discovery.
+- Persisting provider capability snapshots or introducing a database cache.
+- Supporting non-ACP provider-specific configuration controls through this
+ contract.
+- Changing the semantics of workflow runtime application or profile loading.
+
+## Success criteria
+
+- The workflow session override editor displays model-dependent ACP select
+ options returned by a provider, including OpenCode reasoning effort.
+- The OpenCode agent profile settings page displays and saves the same resolved
+ options.
+- A newly added arbitrary select option renders without provider-specific UI
+ code.
+- A model change never displays options from a previous model after an
+ out-of-order response.
+- Resolver failures preserve saved data and provide a retryable, localized
+ status.
+- Desktop and mobile E2E coverage verifies workflow and profile behavior, with
+ touch sizing and overflow assertions for the mobile workflow editor.
diff --git a/docs/specs/workflow-session-settings/spec.md b/docs/specs/workflow-session-settings/spec.md
index 1902418ea5..67ce43650b 100644
--- a/docs/specs/workflow-session-settings/spec.md
+++ b/docs/specs/workflow-session-settings/spec.md
@@ -116,6 +116,8 @@ The backend validates the action on workflow-step create/update and import. Inva
Existing agent capability responses provide the editor's family names, models, and ACP select options. Existing task-session model/configuration mechanisms apply and persist runtime changes.
+When an ACP provider's options depend on the selected model, the editor uses the model-aware capability contract in [Dynamic Provider Model Options](../agents/dynamic-provider-options.md) to replace the baseline option snapshot before saving a rule.
+
## State machine
For each original agent family on each possible workflow path, the editor and runtime use these effective states:
@@ -144,7 +146,7 @@ The same users who can edit a workspace workflow can create, change, or remove c
- If the session is not running but can be launched or resumed, Kandev records the requested overrides first so initialization applies them before the next prompt.
- If `restore_original` has no trustworthy original effective snapshot, Kandev changes nothing, warns that restoration is unavailable for that legacy or incomplete session, and continues the step.
- If an option captured in the original snapshot is no longer advertised, restoration skips that option, restores the remaining fields, and reports the skipped option in the warning.
-- Capability discovery failures keep the conditional editor readable, show the existing agent capability error, and prevent saving a new `set` rule whose values cannot be selected. Existing persisted rules remain visible and removable.
+- Capability discovery keeps the conditional editor readable and shows a localized, retryable status. While discovery is pending, coordinated save is blocked. After a failed discovery, the author may save the selected model without new or unverified option values; existing persisted rules remain visible, and failed discovery does not remove their saved option values.
## Persistence guarantees