fix(canvas): SSOT-drive runtime picker so google-adk shows correctly #2016
@@ -377,11 +377,18 @@ export function billingModeForSelectedProvider(
|
||||
// config.yaml` on the container is a separate runtime-internal file,
|
||||
// not this one.
|
||||
const RUNTIMES_WITH_OWN_CONFIG = new Set<string>(["external", "kimi", "kimi-cli", "openclaw"]);
|
||||
const SUPPORTED_RUNTIME_VALUES = new Set(["claude-code", "codex", "openclaw", "hermes"]);
|
||||
// The runtime picker is SSOT-driven: options come from GET /templates,
|
||||
// which workspace-server already gates to the manifest.json maintained set
|
||||
// (loadRuntimesFromManifest). A hand-maintained frontend allowlist silently
|
||||
// dropped runtimes the backend added (google-adk shipped in manifest but was
|
||||
// filtered out, so its workspaces rendered the wrong default option). A
|
||||
// template may still opt OUT of the picker via `displayable: false` on its
|
||||
// /templates row. See project_canvas_runtime_dropdown_ssot_fix.
|
||||
|
||||
const FALLBACK_RUNTIME_OPTIONS: RuntimeOption[] = [
|
||||
{ value: "claude-code", label: "Claude Code", models: [], providers: [], registryBacked: false, registryProviders: [], registryModels: [] },
|
||||
{ value: "codex", label: "Codex", models: [], providers: [], registryBacked: false, registryProviders: [], registryModels: [] },
|
||||
{ value: "google-adk", label: "Google ADK", models: [], providers: [], registryBacked: false, registryProviders: [], registryModels: [] },
|
||||
{ value: "openclaw", label: "OpenClaw", models: [], providers: [], registryBacked: false, registryProviders: [], registryModels: [] },
|
||||
{ value: "hermes", label: "Hermes", models: [], providers: [], registryBacked: false, registryProviders: [], registryModels: [] },
|
||||
];
|
||||
@@ -585,13 +592,16 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
registry_backed?: boolean;
|
||||
registry_providers?: RegistryProvider[];
|
||||
registry_models?: RegistryModel[];
|
||||
displayable?: boolean;
|
||||
}>>("/templates")
|
||||
.then((rows) => {
|
||||
if (cancelled || !Array.isArray(rows)) return;
|
||||
const byRuntime = new Map<string, RuntimeOption>();
|
||||
for (const r of rows) {
|
||||
const v = (r.runtime || "").trim();
|
||||
if (!SUPPORTED_RUNTIME_VALUES.has(v)) continue;
|
||||
if (!v) continue;
|
||||
// Honor an explicit opt-out; absent/true means show it.
|
||||
if (r.displayable === false) 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);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Regression: project_canvas_runtime_dropdown_ssot_fix — a google-adk
|
||||
// workspace's Config tab showed the wrong runtime ("LangGraph (default)"
|
||||
// / first option) because a hardcoded frontend allowlist
|
||||
// (SUPPORTED_RUNTIME_VALUES) dropped google-adk from the /templates-derived
|
||||
// options even though the backend served it. A Save from that state would
|
||||
// PATCH runtime to the wrong value and break the ADK agent.
|
||||
//
|
||||
// The fix: the dropdown is SSOT-driven — it trusts GET /templates (which the
|
||||
// backend already gates to the manifest maintained set) and hides a runtime
|
||||
// only when its row carries `displayable: false`. This pins: a google-adk
|
||||
// workspace shows "google-adk" selected, and a displayable:false template is
|
||||
// not offered.
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, cleanup, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const apiGet = vi.fn();
|
||||
const apiPatch = vi.fn();
|
||||
const apiPut = vi.fn();
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: (path: string) => apiGet(path),
|
||||
patch: (path: string, body: unknown) => apiPatch(path, body),
|
||||
put: (path: string, body: unknown) => apiPut(path, body),
|
||||
post: vi.fn(),
|
||||
del: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
(selector: (s: unknown) => unknown) => selector({ restartWorkspace: vi.fn(), updateNodeData: vi.fn() }),
|
||||
{ getState: () => ({ restartWorkspace: vi.fn(), updateNodeData: vi.fn() }) },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../AgentCardSection", () => ({
|
||||
AgentCardSection: () => <div data-testid="agent-card-stub" />,
|
||||
}));
|
||||
|
||||
import { ConfigTab } from "../ConfigTab";
|
||||
|
||||
function wireApi(templates: Array<{ id: string; name?: string; runtime?: string; models?: unknown[]; displayable?: boolean }>) {
|
||||
apiGet.mockImplementation((path: string) => {
|
||||
if (path === "/workspaces/ws-adk") return Promise.resolve({ runtime: "google-adk" });
|
||||
if (path === "/workspaces/ws-adk/model") return Promise.resolve({ model: "vertex:gemini-2.5-pro" });
|
||||
if (path === "/workspaces/ws-adk/files/config.yaml") return Promise.resolve({ content: "name: adk\nruntime: google-adk\n" });
|
||||
if (path === "/templates") return Promise.resolve(templates);
|
||||
return Promise.reject(new Error(`unmocked api.get: ${path}`));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
apiPatch.mockReset();
|
||||
apiPut.mockReset();
|
||||
});
|
||||
|
||||
describe("ConfigTab — google-adk runtime (SSOT dropdown)", () => {
|
||||
it("shows google-adk selected in the runtime dropdown (#ssot-fix)", async () => {
|
||||
wireApi([
|
||||
{ id: "claude-code", name: "Claude Code", runtime: "claude-code", models: [] },
|
||||
{ id: "google-adk", name: "Google ADK", runtime: "google-adk", models: [] },
|
||||
]);
|
||||
render(<ConfigTab workspaceId="ws-adk" />);
|
||||
const select = await waitFor(() => screen.getByRole("combobox", { name: /runtime/i }));
|
||||
expect((select as HTMLSelectElement).value).toBe("google-adk");
|
||||
const opts = Array.from((select as HTMLSelectElement).options).map((o) => o.value);
|
||||
expect(opts).toContain("google-adk");
|
||||
});
|
||||
|
||||
it("hides a template flagged displayable:false", async () => {
|
||||
wireApi([
|
||||
{ id: "google-adk", name: "Google ADK", runtime: "google-adk", models: [] },
|
||||
{ id: "legacy", name: "Legacy", runtime: "legacy", models: [], displayable: false },
|
||||
]);
|
||||
render(<ConfigTab workspaceId="ws-adk" />);
|
||||
const select = await waitFor(() => screen.getByRole("combobox", { name: /runtime/i }));
|
||||
const opts = Array.from((select as HTMLSelectElement).options).map((o) => o.value);
|
||||
expect(opts).toContain("google-adk");
|
||||
expect(opts).not.toContain("legacy");
|
||||
});
|
||||
});
|
||||
@@ -224,6 +224,15 @@ type templateSummary struct {
|
||||
// 0 = template hasn't declared one, falls through to canvas's
|
||||
// runtime-profile default.
|
||||
ProvisionTimeoutSeconds int `json:"provision_timeout_seconds,omitempty"`
|
||||
// Displayable lets a template opt OUT of the canvas runtime picker
|
||||
// declaratively (config.yaml `displayable: false`) while still being a
|
||||
// provisionable runtime. nil/absent or true → shown; only an explicit
|
||||
// false hides it. The canvas runtime dropdown is SSOT-driven off this
|
||||
// list (no hardcoded frontend allowlist), so this is the single place a
|
||||
// runtime is hidden from the picker. Pointer so "unset" is distinct from
|
||||
// "false" and omitempty keeps the payload unchanged for existing
|
||||
// templates that never declare it.
|
||||
Displayable *bool `json:"displayable,omitempty"`
|
||||
}
|
||||
|
||||
// resolveTemplateDir finds the template directory for a workspace on the host.
|
||||
@@ -270,6 +279,7 @@ func (h *TemplatesHandler) List(c *gin.Context) {
|
||||
Runtime string `yaml:"runtime"`
|
||||
Model string `yaml:"model"`
|
||||
Skills []string `yaml:"skills"`
|
||||
Displayable *bool `yaml:"displayable"`
|
||||
// Top-level `providers:` block — structured registry. Distinct
|
||||
// from runtime_config.providers (slug list) below. Both shapes
|
||||
// coexist in production: claude-code ships the structured
|
||||
@@ -334,6 +344,7 @@ func (h *TemplatesHandler) List(c *gin.Context) {
|
||||
Skills: raw.Skills,
|
||||
SkillCount: len(raw.Skills),
|
||||
ProvisionTimeoutSeconds: raw.RuntimeConfig.ProvisionTimeoutSeconds,
|
||||
Displayable: raw.Displayable,
|
||||
}
|
||||
|
||||
// internal#718 P3: serve the SELECTABLE provider/model list from
|
||||
|
||||
@@ -1554,3 +1554,86 @@ skills: []
|
||||
t.Errorf("template Providers unchanged: got %v", got.Providers)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemplatesList_DisplayableFlag verifies the SSOT-driven runtime-picker
|
||||
// opt-out: a template's config.yaml `displayable: false` surfaces as a
|
||||
// non-nil false on the /templates row (canvas hides it), while an absent
|
||||
// flag stays nil (canvas shows it) and an explicit true surfaces as true.
|
||||
// This is the backend half of removing the hardcoded frontend allowlist —
|
||||
// the picker trusts this list, so hiding a runtime must be declarative here.
|
||||
func TestTemplatesList_DisplayableFlag(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
mk := func(dir, yaml string) {
|
||||
d := filepath.Join(tmpDir, dir)
|
||||
if err := os.MkdirAll(d, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(d, "config.yaml"), []byte(yaml), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// absent → nil
|
||||
mk("adk-shown", "name: ADK Shown\nruntime: claude-code\n")
|
||||
// explicit false → hidden marker
|
||||
mk("adk-hidden", "name: ADK Hidden\nruntime: claude-code\ndisplayable: false\n")
|
||||
// explicit true → shown marker
|
||||
mk("adk-explicit", "name: ADK Explicit\nruntime: claude-code\ndisplayable: true\n")
|
||||
|
||||
handler := NewTemplatesHandler(tmpDir, nil, 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)
|
||||
}
|
||||
byID := map[string]templateSummary{}
|
||||
for _, s := range resp {
|
||||
byID[s.ID] = s
|
||||
}
|
||||
|
||||
if s, ok := byID["adk-shown"]; !ok {
|
||||
t.Fatal("adk-shown missing")
|
||||
} else if s.Displayable != nil {
|
||||
t.Errorf("adk-shown: expected nil Displayable (absent), got %v", *s.Displayable)
|
||||
}
|
||||
|
||||
if s, ok := byID["adk-hidden"]; !ok {
|
||||
t.Fatal("adk-hidden missing")
|
||||
} else if s.Displayable == nil || *s.Displayable != false {
|
||||
t.Errorf("adk-hidden: expected non-nil false Displayable, got %v", s.Displayable)
|
||||
}
|
||||
|
||||
if s, ok := byID["adk-explicit"]; !ok {
|
||||
t.Fatal("adk-explicit missing")
|
||||
} else if s.Displayable == nil || *s.Displayable != true {
|
||||
t.Errorf("adk-explicit: expected non-nil true Displayable, got %v", s.Displayable)
|
||||
}
|
||||
|
||||
// JSON contract: omitempty drops the field entirely when nil so existing
|
||||
// templates' payloads are byte-unchanged; present when set.
|
||||
var rawRows []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &rawRows); err != nil {
|
||||
t.Fatalf("raw parse: %v", err)
|
||||
}
|
||||
for _, row := range rawRows {
|
||||
id := ""
|
||||
_ = json.Unmarshal(row["id"], &id)
|
||||
_, present := row["displayable"]
|
||||
if id == "adk-shown" && present {
|
||||
t.Error("adk-shown: displayable key should be omitted when nil")
|
||||
}
|
||||
if (id == "adk-hidden" || id == "adk-explicit") && !present {
|
||||
t.Errorf("%s: displayable key should be present when set", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user