fix(canvas): forward-port dynamic runtime dropdown from staging (PR #1526)

PR #1526 shipped the /templates registry + canvas dynamic Runtime /
Model / Required-Env fields on 2026-04-22 — but merged into the
staging branch, not main. The staging→main promotion PR #1496 has
been open unmerged for a while with 1172 commits divergence, so
prod (which builds from main) still carries the old hardcoded
dropdown.

Symptom seen on hongmingwang.moleculesai.app today:

- New Hermes Agent workspace (template declares runtime: hermes) loads
  Config tab → Runtime dropdown shows "LangGraph (default)" because
  there's no <option value="hermes"> in the hardcoded list; it falls
  back to empty-value silently.
- Model field is a plain TextInput with static placeholder
  "e.g. anthropic:claude-sonnet-4-6" — should be a combobox populated
  from the selected runtime's models[].
- Required Env Vars is a TagList with static placeholder
  "e.g. CLAUDE_CODE_OAUTH_TOKEN" — should auto-populate from the
  selected model's required_env.
- Net effect: "Save & Deploy" sends empty model + empty env to the
  provisioner → workspace instant-fails.

This PR cherry-picks the exact three files from PR #1526 (#359dc61
on staging) forward to main, without pulling the other 1171
commits:

- canvas/src/components/tabs/ConfigTab.tsx
  - RuntimeOption interface + FALLBACK_RUNTIME_OPTIONS (hermes,
    gemini-cli included)
  - useEffect fetches /templates and populates runtimeOptions
    dynamically
  - dropdown renders from runtimeOptions (no hardcoded list)
  - Model becomes a combobox with datalist of available models
    per selected runtime
  - Required Env Vars auto-populates from the selected model's
    required_env on model change

- workspace-server/internal/handlers/templates.go
  - /templates endpoint returns [{id, name, runtime, models}] with
    per-template models registry (id, name, required_env)

- workspace-server/internal/handlers/templates_test.go
  - Tests for runtime+models parsing and legacy top-level model
    fallback

The canvas Runtime dropdown now resolves "hermes" correctly;
Model dropdown shows the models[] from the hermes template; Env
auto-populates with HERMES_API_KEY (or whichever model selected).

Verified locally:
  - workspace-server builds clean
  - Template handler tests pass: TestTemplatesList_RuntimeAndModelsRegistry,
    TestTemplatesList_LegacyTopLevelModel, TestTemplatesList_NonexistentDir

Follow-up: the staging→main promotion gap (#1496) is the
underlying process issue. Either merge that PR or adopt a policy
of landing fixes directly on main (as several PRs have today).
Files here were chosen minimally to avoid pulling unrelated staging
changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-22 14:28:38 -07:00
parent 0db8445538
commit f6e6a64ba9
3 changed files with 297 additions and 27 deletions

View File

@ -85,6 +85,36 @@ function AgentCardSection({ workspaceId }: { workspaceId: string }) {
// --- Main ConfigTab ---
interface ModelSpec {
id: string;
name?: string;
required_env?: string[];
}
function arraysEqual(a: readonly string[], b: readonly string[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
interface RuntimeOption {
value: string;
label: string;
models: ModelSpec[];
}
// Fallback used when /templates can't be fetched (offline, older backend).
// Keep in sync with manifest.json workspace_templates as a defensive default.
// Model + env suggestions only flow when the backend is reachable.
const FALLBACK_RUNTIME_OPTIONS: RuntimeOption[] = [
{ value: "", label: "LangGraph (default)", models: [] },
{ value: "claude-code", label: "Claude Code", models: [] },
{ value: "crewai", label: "CrewAI", models: [] },
{ value: "autogen", label: "AutoGen", models: [] },
{ value: "deepagents", label: "DeepAgents", models: [] },
{ value: "openclaw", label: "OpenClaw", models: [] },
{ value: "hermes", label: "Hermes", models: [] },
{ value: "gemini-cli", label: "Gemini CLI", models: [] },
];
export function ConfigTab({ workspaceId }: Props) {
const [config, setConfig] = useState<ConfigData>({ ...DEFAULT_CONFIG });
const [originalYaml, setOriginalYaml] = useState("");
@ -94,6 +124,7 @@ export function ConfigTab({ workspaceId }: Props) {
const [success, setSuccess] = useState(false);
const [rawMode, setRawMode] = useState(false);
const [rawDraft, setRawDraft] = useState("");
const [runtimeOptions, setRuntimeOptions] = useState<RuntimeOption[]>(FALLBACK_RUNTIME_OPTIONS);
const successTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
@ -120,6 +151,36 @@ export function ConfigTab({ workspaceId }: Props) {
loadConfig();
}, [loadConfig]);
useEffect(() => {
let cancelled = false;
api.get<Array<{ id: string; name?: string; runtime?: string; models?: ModelSpec[] }>>("/templates")
.then((rows) => {
if (cancelled || !Array.isArray(rows)) return;
const byRuntime = new Map<string, RuntimeOption>();
byRuntime.set("", { value: "", label: "LangGraph (default)", models: [] });
for (const r of rows) {
const v = (r.runtime || "").trim();
if (!v || v === "langgraph") continue;
// Last template wins if two templates share a runtime — rare, and the
// one with the richer models list is probably newer.
const existing = byRuntime.get(v);
const models = Array.isArray(r.models) ? r.models : [];
if (!existing || models.length > existing.models.length) {
byRuntime.set(v, { value: v, label: r.name || v, models });
}
}
if (byRuntime.size > 1) setRuntimeOptions(Array.from(byRuntime.values()));
})
.catch(() => { /* keep fallback */ });
return () => { cancelled = true; };
}, []);
// Models + env hints for the currently-selected runtime.
const selectedRuntime = runtimeOptions.find((o) => o.value === (config.runtime || "")) ?? null;
const availableModels: ModelSpec[] = selectedRuntime?.models ?? [];
const currentModelId = config.runtime_config?.model || config.model || "";
const currentModelSpec = availableModels.find((m) => m.id === currentModelId) ?? null;
const update = <K extends keyof ConfigData>(key: K, value: ConfigData[K]) => {
setConfig((prev) => ({ ...prev, [key]: value }));
};
@ -259,23 +320,99 @@ export function ConfigTab({ workspaceId }: Props) {
onChange={(e) => update("runtime", e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:border-blue-500"
>
<option value="">LangGraph (default)</option>
<option value="claude-code">Claude Code</option>
<option value="crewai">CrewAI</option>
<option value="autogen">AutoGen</option>
<option value="deepagents">DeepAgents</option>
<option value="openclaw">OpenClaw</option>
{runtimeOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<TextInput label="Model" value={config.runtime_config?.model || config.model || ""} onChange={(v) => {
if (config.runtime) {
update("runtime_config", { ...config.runtime_config, model: v });
} else {
update("model", v);
}
}} placeholder="e.g. anthropic:claude-sonnet-4-6" mono />
<div>
<label className="text-[10px] text-zinc-500 block mb-1">
Model
{availableModels.length > 0 && (
<span className="ml-1 text-zinc-600">({availableModels.length} suggested)</span>
)}
</label>
<input
type="text"
list={availableModels.length > 0 ? `${runtimeId}-models` : undefined}
value={currentModelId}
onChange={(e) => {
const v = e.target.value;
setConfig((prev) => {
// If the new value exactly matches a known modelSpec id,
// swap required_env to that spec's list — but only when
// the current required_env is empty or was itself
// template-driven (i.e. matches the previous modelSpec's
// required_env). User-typed envs always win.
const nextSpec = availableModels.find((m) => m.id === v) ?? null;
const prevModelId = prev.runtime_config?.model || prev.model || "";
const prevSpec = availableModels.find((m) => m.id === prevModelId) ?? null;
const prevRequired = prev.runtime_config?.required_env ?? [];
const wasTemplateDriven =
prevRequired.length === 0 ||
(prevSpec?.required_env?.length
? prevRequired.length === prevSpec.required_env.length &&
prevRequired.every((e, i) => e === prevSpec.required_env![i])
: false);
const nextRequired =
nextSpec?.required_env?.length && wasTemplateDriven
? nextSpec.required_env
: prevRequired;
if (prev.runtime) {
return {
...prev,
runtime_config: {
...prev.runtime_config,
model: v,
...(nextSpec?.required_env?.length && wasTemplateDriven
? { required_env: nextRequired }
: {}),
},
};
}
return { ...prev, model: v };
});
}}
placeholder="e.g. anthropic:claude-sonnet-4-6"
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-xs text-zinc-200 font-mono focus:outline-none focus:border-blue-500"
/>
{availableModels.length > 0 && (
<datalist id={`${runtimeId}-models`}>
{availableModels.map((m, i) => (
<option key={`${m.id}-${i}`} value={m.id}>{m.name || m.id}</option>
))}
</datalist>
)}
</div>
</div>
<TagList label="Required Env Vars" values={config.runtime_config?.required_env || []} onChange={(v) => updateNested("runtime_config" as keyof ConfigData, "required_env", v)} placeholder="e.g. CLAUDE_CODE_OAUTH_TOKEN" />
<TagList
label={
currentModelSpec?.required_env?.length &&
arraysEqual(config.runtime_config?.required_env ?? [], currentModelSpec.required_env)
? "Required Env Vars (from template)"
: "Required Env Vars"
}
values={config.runtime_config?.required_env ?? []}
onChange={(v) => updateNested("runtime_config" as keyof ConfigData, "required_env", v)}
placeholder="e.g. CLAUDE_CODE_OAUTH_TOKEN"
/>
{currentModelSpec?.required_env?.length &&
!arraysEqual(config.runtime_config?.required_env ?? [], currentModelSpec.required_env) && (
<div className="text-[10px] text-zinc-500 mt-1 flex items-center gap-2">
<span>
Template suggests{" "}
<code className="text-zinc-400">{currentModelSpec.required_env.join(", ")}</code>{" "}
for <code className="text-zinc-400">{currentModelSpec.name || currentModelSpec.id}</code>.
</span>
<button
type="button"
onClick={() => updateNested("runtime_config" as keyof ConfigData, "required_env", currentModelSpec.required_env)}
className="text-blue-400 hover:text-blue-300 underline"
>
Apply
</button>
</div>
)}
</Section>
{/* Claude Settings — shown for claude-code runtime or claude/anthropic model names */}

View File

@ -36,14 +36,25 @@ func NewTemplatesHandler(configsDir string, dockerCli *client.Client) *Templates
return &TemplatesHandler{configsDir: configsDir, docker: dockerCli}
}
// modelSpec describes a single supported model on a template: its id (sent
// to the runtime), a human-readable label, and the env vars that must be
// present for that model to work (e.g. API keys).
type modelSpec struct {
ID string `json:"id" yaml:"id"`
Name string `json:"name,omitempty" yaml:"name"`
RequiredEnv []string `json:"required_env,omitempty" yaml:"required_env"`
}
type templateSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Tier int `json:"tier"`
Model string `json:"model"`
Skills []string `json:"skills"`
SkillCount int `json:"skill_count"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Tier int `json:"tier"`
Runtime string `json:"runtime"`
Model string `json:"model"`
Models []modelSpec `json:"models,omitempty"`
Skills []string `json:"skills"`
SkillCount int `json:"skill_count"`
}
// resolveTemplateDir finds the template directory for a workspace on the host.
@ -82,22 +93,35 @@ func (h *TemplatesHandler) List(c *gin.Context) {
}
var raw struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Tier int `yaml:"tier"`
Model string `yaml:"model"`
Skills []string `yaml:"skills"`
Name string `yaml:"name"`
Description string `yaml:"description"`
Tier int `yaml:"tier"`
Runtime string `yaml:"runtime"`
Model string `yaml:"model"`
Skills []string `yaml:"skills"`
RuntimeConfig struct {
Model string `yaml:"model"`
Models []modelSpec `yaml:"models"`
} `yaml:"runtime_config"`
}
if err := yaml.Unmarshal(data, &raw); err != nil {
continue
}
// Model comes from either top-level (legacy) or runtime_config.model (current).
model := raw.Model
if model == "" {
model = raw.RuntimeConfig.Model
}
templates = append(templates, templateSummary{
ID: entry.Name(),
Name: raw.Name,
Description: raw.Description,
Tier: raw.Tier,
Model: raw.Model,
Runtime: raw.Runtime,
Model: model,
Models: raw.RuntimeConfig.Models,
Skills: raw.Skills,
SkillCount: len(raw.Skills),
})

View File

@ -129,6 +129,115 @@ skills:
}
}
func TestTemplatesList_RuntimeAndModelsRegistry(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
tmpDir := t.TempDir()
tmplDir := filepath.Join(tmpDir, "hermes")
if err := os.MkdirAll(tmplDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
configYaml := `name: Hermes Agent
description: test
tier: 2
runtime: hermes
runtime_config:
model: nous-hermes-3-70b
models:
- id: nous-hermes-3-70b
name: Nous Hermes 3 70B
required_env: [HERMES_API_KEY]
- id: minimax/minimax-m2.7
name: MiniMax M2.7 (via OpenRouter)
required_env: [OPENROUTER_API_KEY]
skills: []
`
if err := os.WriteFile(filepath.Join(tmplDir, "config.yaml"), []byte(configYaml), 0644); err != nil {
t.Fatalf("write: %v", err)
}
handler := NewTemplatesHandler(tmpDir, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/templates", nil)
handler.List(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp []templateSummary
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("parse: %v", err)
}
if len(resp) != 1 {
t.Fatalf("expected 1 template, got %d", len(resp))
}
got := resp[0]
if got.Runtime != "hermes" {
t.Errorf("Runtime: want hermes, got %q", got.Runtime)
}
if got.Model != "nous-hermes-3-70b" {
t.Errorf("Model: want nous-hermes-3-70b (from runtime_config.model), got %q", got.Model)
}
if len(got.Models) != 2 {
t.Fatalf("Models: want 2, got %d", len(got.Models))
}
if got.Models[0].ID != "nous-hermes-3-70b" || got.Models[0].Name != "Nous Hermes 3 70B" {
t.Errorf("Models[0] id/name mismatch: %+v", got.Models[0])
}
if len(got.Models[0].RequiredEnv) != 1 || got.Models[0].RequiredEnv[0] != "HERMES_API_KEY" {
t.Errorf("Models[0] required_env: want [HERMES_API_KEY], got %+v", got.Models[0].RequiredEnv)
}
if got.Models[1].ID != "minimax/minimax-m2.7" {
t.Errorf("Models[1].ID: got %q", got.Models[1].ID)
}
if len(got.Models[1].RequiredEnv) != 1 || got.Models[1].RequiredEnv[0] != "OPENROUTER_API_KEY" {
t.Errorf("Models[1] required_env: want [OPENROUTER_API_KEY], got %+v", got.Models[1].RequiredEnv)
}
}
func TestTemplatesList_LegacyTopLevelModel(t *testing.T) {
// Older templates (pre-runtime_config) declared `model:` at the top level.
// The /templates endpoint should keep surfacing those for backward compat.
setupTestDB(t)
setupTestRedis(t)
tmpDir := t.TempDir()
tmplDir := filepath.Join(tmpDir, "legacy")
if err := os.MkdirAll(tmplDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
configYaml := `name: Legacy Agent
tier: 1
model: anthropic:claude-sonnet-4-6
skills: []
`
if err := os.WriteFile(filepath.Join(tmplDir, "config.yaml"), []byte(configYaml), 0644); err != nil {
t.Fatalf("write: %v", err)
}
handler := NewTemplatesHandler(tmpDir, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/templates", nil)
handler.List(c)
var resp []templateSummary
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("parse: %v", err)
}
if len(resp) != 1 || resp[0].Model != "anthropic:claude-sonnet-4-6" {
t.Errorf("legacy top-level model not surfaced: %+v", resp)
}
if resp[0].Runtime != "" {
t.Errorf("Runtime should be empty for legacy template, got %q", resp[0].Runtime)
}
if len(resp[0].Models) != 0 {
t.Errorf("Models should be empty for legacy template, got %+v", resp[0].Models)
}
}
func TestTemplatesList_NonexistentDir(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)