fix(canvas): SSOT-drive runtime picker so google-adk shows correctly #2016

Merged
core-be merged 2 commits from feat/google-adk-runtime-ssot into main 2026-05-31 09:46:31 +00:00
4 changed files with 193 additions and 2 deletions
+12 -2
View File
@@ -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)
}
}
}