fix: prevent utility agents settings page crash on null models (#602)

This commit is contained in:
Carlos Florêncio
2026-04-17 14:22:34 +01:00
committed by GitHub
parent f98727aac9
commit 181a64a5fb
12 changed files with 591 additions and 38 deletions
+22 -1
View File
@@ -75,6 +75,9 @@ func (a *mockAgent) Initialize(_ context.Context, _ acp.InitializeRequest) (acp.
// NewSession creates a new conversation session.
// MCP servers from the ACP request are registered so callMCPTool can use them.
// The Models field advertises the available models so the host utility
// capability probe can populate them in the cache — this is what makes
// the utility-agents settings page show model options for mock-agent in E2E.
func (a *mockAgent) NewSession(_ context.Context, req acp.NewSessionRequest) (acp.NewSessionResponse, error) {
sid := acp.SessionId(fmt.Sprintf("mock-session-%d", os.Getpid()))
a.mu.Lock()
@@ -85,7 +88,25 @@ func (a *mockAgent) NewSession(_ context.Context, req acp.NewSessionRequest) (ac
// This bridges ACP protocol MCP config to the mock agent's MCP client.
registerACPMcpServers(req.McpServers)
return acp.NewSessionResponse{SessionId: sid}, nil
return acp.NewSessionResponse{
SessionId: sid,
Models: mockSessionModels(),
}, nil
}
// mockSessionModels returns the mock agent's advertised model list for ACP
// session responses. Two models are exposed so tests can verify both
// selection and default behavior (mock-fast is the default).
func mockSessionModels() *acp.UnstableSessionModelState {
fastDesc := "Fast mock model for testing"
smartDesc := "Smart mock model for testing"
return &acp.UnstableSessionModelState{
CurrentModelId: "mock-fast",
AvailableModels: []acp.UnstableModelInfo{
{ModelId: "mock-fast", Name: "Mock Fast", Description: &fastDesc},
{ModelId: "mock-smart", Name: "Mock Smart", Description: &smartDesc},
},
}
}
// LoadSession restores a previous session for resume.
@@ -17,6 +17,7 @@ var mockLogoDark []byte
var (
_ Agent = (*MockAgent)(nil)
_ PassthroughAgent = (*MockAgent)(nil)
_ InferenceAgent = (*MockAgent)(nil)
)
type MockAgent struct {
@@ -113,3 +114,18 @@ func (a *MockAgent) InstallScript() string { return "" }
func (a *MockAgent) PermissionSettings() map[string]PermissionSetting {
return emptyPermSettings
}
// InferenceConfig enables one-shot inference via ACP. The mock-agent binary
// advertises its available models in the session/new response, so the host
// utility capability probe populates them into the cache without any static
// model list here.
func (a *MockAgent) InferenceConfig() *InferenceConfig {
binary := "mock-agent"
if a.binaryPath != "" {
binary = a.binaryPath
}
return &InferenceConfig{
Supported: true,
Command: NewCommand(binary),
}
}
@@ -403,9 +403,10 @@ func derefString(p *string) string {
// is not derived from untrusted input — even though the value is
// semantically the same as the base name taken from InferenceConfig.Command.
var allowedProbeCommands = map[string]string{
"npx": "npx",
"auggie": "auggie",
"opencode": "opencode",
"npx": "npx",
"auggie": "auggie",
"opencode": "opencode",
"mock-agent": "mock-agent",
}
// resolveProbeCommand validates and returns a hard-coded executable name for
+5
View File
@@ -162,6 +162,11 @@ type InferenceModelDTO struct {
Name string `json:"name"`
Description string `json:"description"`
IsDefault bool `json:"is_default"`
// Meta carries agent-specific extras from ACP's `_meta` field. For
// GitHub Copilot this includes `copilotUsage` (e.g. "1x", "0.33x",
// "0x" — the premium-request multiplier) which the UI renders as a
// cost badge next to the model name.
Meta map[string]any `json:"meta,omitempty"`
}
// InferenceAgentDTO represents an agent that supports inference.
@@ -27,9 +27,11 @@ type InferenceExecutor interface {
}
// HostUtilityExecutor runs sessionless utility prompts via the long-lived
// per-agent-type host agentctl instances.
// per-agent-type host agentctl instances and exposes the cached per-agent
// capabilities (models, modes) populated by the boot-time ACP probe.
type HostUtilityExecutor interface {
ExecutePrompt(ctx context.Context, agentType, model, mode, prompt string) (*hostutility.PromptResult, error)
Get(agentType string) (hostutility.AgentCapabilities, bool)
}
// UserSettingsProvider provides user settings for default utility agent/model.
@@ -310,15 +312,43 @@ func (h *Handlers) httpListCalls(c *gin.Context) {
func (h *Handlers) httpListInferenceAgents(c *gin.Context) {
inferenceAgents := h.executor.ListInferenceAgentsWithContext(c.Request.Context())
// Convert to DTO. Models are no longer listed per-agent here — the
// frontend should read them from the host utility capability cache via
// GET /api/v1/agents/:type/capabilities.
// Build the response from the host utility capability cache (boot-time
// ACP probe). Only include agents whose probe reached StatusOK — an
// agent that isn't authenticated, not installed, or still probing
// can't actually run a utility prompt, so showing it in the picker
// just leads the user into a dead end.
//
// hostExecutor is an optional dependency (see executeSessionless);
// without it we can't check health or surface models, so the list is
// empty by design rather than showing unusable options.
result := make([]dto.InferenceAgentDTO, 0, len(inferenceAgents))
if h.hostExecutor == nil {
c.JSON(http.StatusOK, dto.InferenceAgentsResponse{Agents: result})
return
}
for _, ia := range inferenceAgents {
// The cache is keyed by ag.ID() (see bootstrapAgent), not
// ag.Name() — built-in ACP agents return distinct strings for the
// two (e.g. "claude-acp" vs "Claude ACP Agent").
caps, ok := h.hostExecutor.Get(ia.ID)
if !ok || caps.Status != hostutility.StatusOK {
continue
}
models := make([]dto.InferenceModelDTO, 0, len(caps.Models))
for _, m := range caps.Models {
models = append(models, dto.InferenceModelDTO{
ID: m.ID,
Name: m.Name,
Description: m.Description,
IsDefault: m.ID == caps.CurrentModelID,
Meta: m.Meta,
})
}
result = append(result, dto.InferenceAgentDTO{
ID: ia.ID,
Name: ia.Name,
DisplayName: ia.DisplayName,
Models: models,
})
}
c.JSON(http.StatusOK, dto.InferenceAgentsResponse{Agents: result})
@@ -0,0 +1,283 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/agent/hostutility"
"github.com/kandev/kandev/internal/agent/lifecycle"
agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility"
"github.com/kandev/kandev/internal/common/logger"
)
type stubInferenceExecutor struct {
agents []lifecycle.InferenceAgentInfo
}
func (s *stubInferenceExecutor) ExecuteInferencePrompt(_ context.Context, _, _, _, _ string) (*agentctlutil.PromptResponse, error) {
return nil, nil
}
func (s *stubInferenceExecutor) ListInferenceAgentsWithContext(_ context.Context) []lifecycle.InferenceAgentInfo {
return s.agents
}
type stubHostUtility struct {
caps map[string]hostutility.AgentCapabilities
}
func (s *stubHostUtility) ExecutePrompt(_ context.Context, _, _, _, _ string) (*hostutility.PromptResult, error) {
return nil, nil
}
func (s *stubHostUtility) Get(agentType string) (hostutility.AgentCapabilities, bool) {
c, ok := s.caps[agentType]
return c, ok
}
// wantModel is the expected shape of a single model in the response for a
// given agent.
type wantModel struct {
id string
isDefault bool
meta map[string]any
}
// TestHttpListInferenceAgents covers the full /api/v1/utility/inference-agents
// response contract:
//
// - Only agents whose host utility probe reached StatusOK are included —
// an agent that needs auth, isn't installed, or is still probing can't
// actually run a utility prompt, so it must be filtered out of the
// picker rather than leading the user into a dead end.
// - `models` is always a JSON array, never null — a null slice would
// crash the frontend's flatMap over `ia.models`.
// - `is_default` is set on the model matching CurrentModelID.
// - `meta` (e.g. Copilot's `copilotUsage` cost multiplier) propagates
// through to the DTO so the model combobox can render cost badges.
func TestHttpListInferenceAgents(t *testing.T) {
gin.SetMode(gin.TestMode)
// Fixtures use the real built-in ACP agent shape (distinct ID/Name) so
// a future ID/Name mixup in the cache lookup fails the test.
claude := lifecycle.InferenceAgentInfo{ID: "claude-acp", Name: "Claude ACP Agent", DisplayName: "Claude"}
codex := lifecycle.InferenceAgentInfo{ID: "codex-acp", Name: "Codex ACP Agent", DisplayName: "Codex"}
copilot := lifecycle.InferenceAgentInfo{ID: "copilot-acp", Name: "Copilot ACP Agent", DisplayName: "Copilot"}
tests := []struct {
name string
agents []lifecycle.InferenceAgentInfo
caps map[string]hostutility.AgentCapabilities
nilHost bool
wantByAgent map[string][]wantModel
}{
{
name: "healthy agent with cached models is included with is_default",
agents: []lifecycle.InferenceAgentInfo{claude},
caps: map[string]hostutility.AgentCapabilities{
"claude-acp": {
AgentType: "claude-acp",
Status: hostutility.StatusOK,
CurrentModelID: "sonnet",
Models: []hostutility.Model{
{ID: "sonnet", Name: "Sonnet"},
{ID: "opus", Name: "Opus"},
},
},
},
wantByAgent: map[string][]wantModel{
"Claude ACP Agent": {
{id: "sonnet", isDefault: true},
{id: "opus", isDefault: false},
},
},
},
{
// Primary UX bug this filter guards against: Codex/Auggie with
// lock icons (auth_required) appearing in the utility picker.
name: "auth_required agent is filtered out",
agents: []lifecycle.InferenceAgentInfo{claude, codex},
caps: map[string]hostutility.AgentCapabilities{
"claude-acp": {
AgentType: "claude-acp",
Status: hostutility.StatusOK,
Models: []hostutility.Model{{ID: "sonnet", Name: "Sonnet"}},
},
"codex-acp": {
AgentType: "codex-acp",
Status: hostutility.StatusAuthRequired,
},
},
wantByAgent: map[string][]wantModel{
"Claude ACP Agent": {{id: "sonnet", isDefault: false}},
},
},
{
name: "failed and probing agents are filtered out",
agents: []lifecycle.InferenceAgentInfo{claude, codex, copilot},
caps: map[string]hostutility.AgentCapabilities{
"claude-acp": {Status: hostutility.StatusFailed},
"codex-acp": {Status: hostutility.StatusProbing},
"copilot-acp": {Status: hostutility.StatusOK, Models: []hostutility.Model{{ID: "gpt-5", Name: "GPT-5"}}},
},
wantByAgent: map[string][]wantModel{
"Copilot ACP Agent": {{id: "gpt-5", isDefault: false}},
},
},
{
name: "agent with no cache entry is filtered out",
agents: []lifecycle.InferenceAgentInfo{claude},
caps: nil,
wantByAgent: map[string][]wantModel{},
},
{
name: "model meta (copilot cost) propagates to DTO",
agents: []lifecycle.InferenceAgentInfo{copilot},
caps: map[string]hostutility.AgentCapabilities{
"copilot-acp": {
AgentType: "copilot-acp",
Status: hostutility.StatusOK,
CurrentModelID: "gpt-5",
Models: []hostutility.Model{
{ID: "gpt-5", Name: "GPT-5", Meta: map[string]any{"copilotUsage": "1x"}},
{ID: "gpt-5-mini", Name: "GPT-5 Mini", Meta: map[string]any{"copilotUsage": "0.33x"}},
},
},
},
wantByAgent: map[string][]wantModel{
"Copilot ACP Agent": {
{id: "gpt-5", isDefault: true, meta: map[string]any{"copilotUsage": "1x"}},
{id: "gpt-5-mini", isDefault: false, meta: map[string]any{"copilotUsage": "0.33x"}},
},
},
},
{
name: "no inference agents returns empty list",
agents: nil,
caps: nil,
wantByAgent: map[string][]wantModel{},
},
{
// hostExecutor is optional throughout the package (see the nil
// guard in executeSessionless). Without it we can't check
// health, so the list is empty — never a panic.
name: "nil hostExecutor yields empty list, no panic",
agents: []lifecycle.InferenceAgentInfo{claude},
nilHost: true,
wantByAgent: map[string][]wantModel{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
h := &Handlers{
executor: &stubInferenceExecutor{agents: tt.agents},
logger: log,
}
if !tt.nilHost {
h.hostExecutor = &stubHostUtility{caps: tt.caps}
}
router := gin.New()
router.GET("/api/v1/utility/inference-agents", h.httpListInferenceAgents)
req := httptest.NewRequest(http.MethodGet, "/api/v1/utility/inference-agents", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
// The raw body must never contain "models":null — that shape
// crashed the frontend before the original fix.
body := rec.Body.String()
if strings.Contains(body, `"models":null`) {
t.Fatalf("response must never contain \"models\":null, got body: %s", body)
}
var resp struct {
Agents []struct {
Name string `json:"name"`
Models []struct {
ID string `json:"id"`
IsDefault bool `json:"is_default"`
Meta map[string]any `json:"meta,omitempty"`
} `json:"models"`
} `json:"agents"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if len(resp.Agents) != len(tt.wantByAgent) {
t.Fatalf("got %d agents (%v), want %d", len(resp.Agents), agentNames(resp.Agents), len(tt.wantByAgent))
}
for _, a := range resp.Agents {
if a.Models == nil {
t.Errorf("agent %q: models slice decoded as nil (should be empty slice, never nil)", a.Name)
}
want, ok := tt.wantByAgent[a.Name]
if !ok {
t.Errorf("unexpected agent %q in response", a.Name)
continue
}
if len(a.Models) != len(want) {
t.Errorf("agent %q: got %d models, want %d", a.Name, len(a.Models), len(want))
continue
}
for i, m := range a.Models {
if m.ID != want[i].id {
t.Errorf("agent %q model[%d]: got id %q, want %q", a.Name, i, m.ID, want[i].id)
}
if m.IsDefault != want[i].isDefault {
t.Errorf("agent %q model %q: got is_default=%v, want %v", a.Name, m.ID, m.IsDefault, want[i].isDefault)
}
if !equalMeta(m.Meta, want[i].meta) {
t.Errorf("agent %q model %q: got meta=%v, want %v", a.Name, m.ID, m.Meta, want[i].meta)
}
}
}
})
}
}
func agentNames(agents []struct {
Name string `json:"name"`
Models []struct {
ID string `json:"id"`
IsDefault bool `json:"is_default"`
Meta map[string]any `json:"meta,omitempty"`
} `json:"models"`
}) []string {
names := make([]string, 0, len(agents))
for _, a := range agents {
names = append(names, a.Name)
}
return names
}
// equalMeta does a shallow equality check on the meta maps. A nil want map
// matches a nil or empty got map (JSON omits empty maps via omitempty).
func equalMeta(got, want map[string]any) bool {
if len(want) == 0 {
return len(got) == 0
}
if len(got) != len(want) {
return false
}
for k, v := range want {
if got[k] != v {
return false
}
}
return true
}
@@ -6,6 +6,7 @@ import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { ModelCombobox } from "@/components/settings/model-combobox";
import {
type UtilityAgent,
createUtilityAgent,
@@ -87,18 +88,14 @@ function AgentModelSelect({
</div>
<div className="space-y-2">
<Label>Model</Label>
<Select value={model} onValueChange={onModelChange} disabled={availableModels.length === 0}>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder="Select model..." />
</SelectTrigger>
<SelectContent>
{availableModels.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
<ModelCombobox
value={model}
onChange={onModelChange}
models={availableModels}
currentModelId={availableModels.find((m) => m.is_default)?.id}
placeholder="Select model..."
disabled={availableModels.length === 0}
/>
</div>
</div>
);
@@ -199,7 +196,7 @@ export function UtilityAgentDialog({ open, onOpenChange, agent, onSuccess }: Pro
// Auto-select default model when agent changes
useEffect(() => {
if (selectedAgent && !form.model) {
const defaultModel = selectedAgent.models.find((m) => m.is_default);
const defaultModel = (selectedAgent.models ?? []).find((m) => m.is_default);
if (defaultModel) {
setForm((f) => ({ ...f, model: defaultModel.id }));
}
@@ -20,7 +20,7 @@ import {
function buildAllModels(inferenceAgents: InferenceAgent[]) {
return inferenceAgents.flatMap((ia) =>
ia.models.map((m) => ({
(ia.models ?? []).map((m) => ({
value: `${ia.id}|${m.id}`,
label: `${ia.display_name} / ${m.name}`,
agentName: ia.display_name,
@@ -5,6 +5,7 @@ import { Button } from "@kandev/ui/button";
import { Label } from "@kandev/ui/label";
import { Separator } from "@kandev/ui/separator";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { ModelCombobox } from "@/components/settings/model-combobox";
import type { UtilityAgent, InferenceAgent } from "@/lib/api/domains/utility-api";
const USE_DEFAULT = "__USE_DEFAULT__";
@@ -27,6 +28,7 @@ export function DefaultModelSection({
}: DefaultModelSectionProps) {
const selectedAgent = inferenceAgents.find((a) => a.id === defaultAgentId);
const modelOptions = selectedAgent?.models ?? [];
const currentModelId = modelOptions.find((m) => m.is_default)?.id;
return (
<div className="space-y-3">
@@ -52,24 +54,16 @@ export function DefaultModelSection({
</SelectContent>
</Select>
</div>
<div className="w-[180px]">
<div className="w-[220px]">
<Label className="text-xs text-muted-foreground mb-1 block">Model</Label>
<Select
<ModelCombobox
value={defaultModel}
onValueChange={(v) => onDefaultChange(defaultAgentId, v)}
onChange={(v) => onDefaultChange(defaultAgentId, v)}
models={modelOptions}
currentModelId={currentModelId}
placeholder="Select model..."
disabled={!defaultAgentId}
>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder="Select model..." />
</SelectTrigger>
<SelectContent>
{modelOptions.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
/>
</div>
</div>
</div>
+5 -1
View File
@@ -223,7 +223,11 @@ exec git "$@"
const backendEnv = {
...stripGitHubTokens(process.env as Record<string, string>),
PATH: `${shimDir}:${originalPath}`,
// Prepend the kandev bin dir so the host utility probe can locate
// the `mock-agent` binary via PATH. In production that dir is the
// same as the running kandev binary's dir, but e2e spawns via an
// absolute path and doesn't inherit that location.
PATH: `${shimDir}:${path.join(BACKEND_DIR, "bin")}:${originalPath}`,
KANDEV_E2E_ORIGINAL_PATH: originalPath,
KANDEV_E2E_GIT_DELAY_FILE: shimDelayFile,
HOME: tmpDir,
@@ -0,0 +1,195 @@
import { test, expect } from "../../fixtures/test-base";
/**
* Covers the Utility Agents settings page.
*
* The first test is a regression guard for a bug where the backend emitted
* `models: null` on /api/v1/utility/inference-agents. The frontend's flatMap
* over `ia.models` blew up and crashed the whole settings page during render.
* The other tests smoke-check the page loads and walk through the main
* interactions (open the page, inspect sections, open the create dialog).
*/
test.describe("Utility Agents settings page", () => {
test("does not crash when backend returns models: null", async ({ testPage }) => {
// Simulate the exact shape the backend used to emit. Guards against a
// regression where frontend null-deref would take the whole page down.
await testPage.route("**/api/v1/utility/inference-agents", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
agents: [
{
id: "broken-agent",
name: "broken-agent",
display_name: "Broken Agent",
models: null,
},
],
}),
}),
);
const pageErrors: Error[] = [];
testPage.on("pageerror", (err) => pageErrors.push(err));
await testPage.goto("/settings/utility-agents");
await expect(
testPage.getByRole("heading", { name: "Utility Agents", exact: true }),
).toBeVisible({ timeout: 15_000 });
expect(pageErrors, `uncaught errors: ${pageErrors.map((e) => e.message).join("; ")}`).toEqual(
[],
);
});
test("renders all sections with seeded built-in utility agents", async ({ testPage }) => {
const pageErrors: Error[] = [];
testPage.on("pageerror", (err) => pageErrors.push(err));
await testPage.goto("/settings/utility-agents");
// Top-level heading + subtitle.
await expect(
testPage.getByRole("heading", { name: "Utility Agents", exact: true }),
).toBeVisible({ timeout: 15_000 });
await expect(
testPage.getByText("One-shot AI helpers for commits, PRs, and prompts."),
).toBeVisible();
// Default-model section.
await expect(
testPage.getByRole("heading", { name: "Default utility agent model", exact: true }),
).toBeVisible();
// Built-in actions (seeded on first boot — see builtins.go).
// Assert a representative subset; the full list lives server-side.
await expect(testPage.getByText("commit-message", { exact: true })).toBeVisible();
await expect(testPage.getByText("pr-title", { exact: true })).toBeVisible();
await expect(testPage.getByText("enhance-prompt", { exact: true })).toBeVisible();
// Custom agents section + empty state.
await expect(
testPage.getByRole("heading", { name: "Custom utility agents", exact: true }),
).toBeVisible();
await expect(testPage.getByText("No custom utility agents.")).toBeVisible();
expect(pageErrors, `uncaught errors: ${pageErrors.map((e) => e.message).join("; ")}`).toEqual(
[],
);
});
test("opens the create-agent dialog from the Add button", async ({ testPage }) => {
const pageErrors: Error[] = [];
testPage.on("pageerror", (err) => pageErrors.push(err));
await testPage.goto("/settings/utility-agents");
await expect(
testPage.getByRole("heading", { name: "Utility Agents", exact: true }),
).toBeVisible({ timeout: 15_000 });
await testPage.getByRole("button", { name: "Add", exact: true }).click();
// The dialog is rendered by UtilityAgentDialog; title differs between
// create and edit mode. We're in create mode here.
const dialog = testPage.getByRole("dialog");
await expect(dialog).toBeVisible({ timeout: 5_000 });
await expect(dialog.getByText("Create Utility Agent")).toBeVisible();
// Close the dialog — nothing should explode.
await dialog.getByRole("button", { name: "Cancel" }).click();
await expect(dialog).not.toBeVisible();
expect(pageErrors, `uncaught errors: ${pageErrors.map((e) => e.message).join("; ")}`).toEqual(
[],
);
});
test("selecting an agent populates the model combobox (ACP probe)", async ({
testPage,
backend,
}) => {
// Regression guard for "I select an agent but can't select a model".
// The mock-agent binary advertises `mock-fast` (default) and `mock-smart`
// in its session/new response, so the boot-time ACP probe populates the
// host utility capability cache. The backend filters out agents whose
// probe didn't reach StatusOK, so in E2E (KANDEV_MOCK_AGENT=only) the
// Agent dropdown should show exactly one option: Mock.
const pageErrors: Error[] = [];
testPage.on("pageerror", (err) => pageErrors.push(err));
// The probe runs in a goroutine at boot, so the first page load may
// land before the cache is populated. Poll the backend directly until
// mock-agent is reported with its models so the UI assertions below
// aren't racing the probe.
await expect
.poll(
async () => {
const resp = await testPage.request.get(
`${backend.baseUrl}/api/v1/utility/inference-agents`,
);
if (!resp.ok()) return 0;
const data = (await resp.json()) as {
agents: { id: string; models?: { id: string }[] | null }[];
};
const mock = data.agents.find((a) => a.id === "mock-agent");
return mock?.models?.length ?? 0;
},
{ timeout: 15_000, intervals: [250, 500, 1000] },
)
.toBeGreaterThanOrEqual(2);
await testPage.goto("/settings/utility-agents");
await expect(
testPage.getByRole("heading", { name: "Utility Agents", exact: true }),
).toBeVisible({ timeout: 15_000 });
// The default-model section has an Agent select (shadcn) and a Model
// combobox (the shared ModelCombobox from the profile page). Each is
// scoped by the Label above it (no `htmlFor`).
const agentSelect = testPage
.locator('div:has(> label:text-is("Agent"))')
.first()
.getByRole("combobox");
const modelCombobox = testPage
.locator('div:has(> label:text-is("Model"))')
.first()
.getByRole("combobox");
// Model combobox starts disabled until an agent is picked.
await expect(modelCombobox).toBeDisabled();
// Open the Agent dropdown: the only healthy option in E2E is Mock.
// This implicitly guards the backend filter — if an auth_required or
// still-probing agent had leaked through, it would show up here too.
await agentSelect.click();
const agentListbox = testPage.getByRole("listbox");
await expect(agentListbox).toBeVisible();
await expect(agentListbox.getByRole("option")).toHaveCount(1);
await expect(agentListbox.getByRole("option", { name: "Mock", exact: true })).toBeVisible();
await agentListbox.getByRole("option", { name: "Mock", exact: true }).click();
await expect(agentListbox).not.toBeVisible();
// Model combobox is now enabled. Open the popover and verify both
// probed models are listed as command items, along with the "(default)"
// badge on mock-fast.
await expect(modelCombobox).toBeEnabled();
await modelCombobox.click();
await expect(testPage.getByRole("option", { name: /Mock Fast.*\(default\)/ })).toBeVisible();
await expect(testPage.getByRole("option", { name: /Mock Smart/ })).toBeVisible();
// Search input is part of the ModelCombobox — filtering narrows the list.
await testPage.getByPlaceholder("Search models...").fill("smart");
await expect(testPage.getByRole("option", { name: /Mock Fast/ })).toHaveCount(0);
// Pick Mock Smart and verify the trigger reflects the selection.
await testPage.getByRole("option", { name: /Mock Smart/ }).click();
await expect(modelCombobox).toContainText("Mock Smart");
expect(pageErrors, `uncaught errors: ${pageErrors.map((e) => e.message).join("; ")}`).toEqual(
[],
);
});
});
+7
View File
@@ -42,6 +42,13 @@ export type InferenceModel = {
name: string;
description: string;
is_default: boolean;
/**
* Agent-specific extras from ACP's `_meta` field. GitHub Copilot exposes
* `copilotUsage` (e.g. "1x", "0.33x", "0x" — the premium-request
* multiplier) which the model combobox renders as a cost badge. Shape
* matches `ModelEntry.meta` so the same `<ModelCombobox>` accepts this.
*/
meta?: Record<string, unknown>;
};
export type InferenceAgent = {