48a621a566
* fix(utility): surface probe status in inference-agents endpoint
GET /api/v1/utility/inference-agents previously filtered out agents whose
host-utility probe didn't reach StatusOK, so a Claude entry with auth or
install issues vanished from the response. The frontend then defaulted
agent_id to "claude-acp", failed to match anything in the list, and
silently rendered an empty Model picker with no explanation.
Include every registered ACP-capable agent in the response with a new
`status` field (and sanitized `status_message`) so the UI can render an
inline note + Refresh button. Add a companion
POST /api/v1/utility/inference-agents/:id/refresh endpoint backed by the
existing hostutility.Manager.Refresh so users can re-probe after fixing
auth or installing the CLI without restarting kandev.
Credential-looking substrings ("api_key=...", "token=...") in probe
errors are stripped before reaching the wire.
Refs #1269, #1263.
* fix(web): show probe status note + Refresh on utility-agent model picker
Previously the Model picker rendered empty and disabled whenever the
selected inference agent was missing from /inference-agents (probe
hadn't reached StatusOK) or advertised no models, with no way for the
user to understand or recover.
Wire the new `status` field through the API client and render an inline
note ("Sign in to Claude to load models.", "Claude CLI is not installed
on this machine.", "Setting up Claude…") with a Refresh button that
calls the new POST /inference-agents/:id/refresh endpoint and merges the
updated agent back into local state — no full refetch, so a slow probe
on a different agent doesn't block the UI.
Both call sites — the create/edit dialog and the Default Model row in
the settings page — render the note. Component-level unit tests cover
the status-to-copy mapping, fallback ("no longer available") copy, and
the in-flight Refresh button disabled state. The e2e test asserts the
auth_required → click Refresh → status:ok state machine end to end.
Refs #1269.
* test(utility): add HTTP integration tests for inference-agents endpoints
End-to-end coverage of the new /api/v1/utility/inference-agents and
/api/v1/utility/inference-agents/:id/refresh routes against a real gin
router with a stateful in-memory host stub.
- ProbeStateMachine: GET (auth_required) → POST refresh → GET (ok+models)
- RefreshUnknownAgent: 404 without invoking the host probe
- RefreshPathFuzz: traversal, null byte, 512-char id — no 5xx, no probe
- ConcurrentRefresh: 50 parallel POSTs under -race, no panic
- StatusMessageSanitizationOnTheWire: 110KB payload + unicode probe
errors — no secret material reaches the wire, unicode prose preserved
Caught the gap unit tests had: the multi-request GET→POST→GET sequence
on a shared cache, and the wire format (Content-Type, JSON shape)
through the real router.
* fix(utility): tighten credential regex + drop Go 1.22 loop-var shadow
Address two inline review comments from claude bot on PR #1287:
- Drop `ia := ia` shadow in httpRefreshInferenceAgent — pre-Go 1.22
pointer-capture workaround, dead weight under Go 1.24+ (per
apps/backend/AGENTS.md).
- Split the credential sanitizer into two patterns so prose without a
real `=`/`:` separator is no longer mangled. The single regex
previously turned "access token was revoked" into
"access token=<redacted> revoked" by consuming the next word as the
value. New shape:
- credentialKVPattern — requires `[:=]` (api_key, token, secret,
password)
- credentialBearerPattern — allows bare whitespace, anchored to
"bearer" only
Existing redaction cases still pass; two new prose cases guard the
regression.
* fix(utility): address cubic + greptile review findings on PR #1287
Round-2 review feedback:
- P0 (cubic): postEmpty called `t.Fatalf` from `go func()` in
TestIntegration_ConcurrentRefresh. Go panics on t.Fatalf from non-test
goroutines. Split into `postEmptyE` (error-returning, goroutine-safe)
and `postEmpty` (main-goroutine wrapper). Concurrent test now
forwards the error through the existing `errs` channel.
- P1 (cubic + greptile): tightened the credential sanitizer regex.
* `api[ _-]?key` now also matches a literal space ("invalid api key=…")
* separators are restricted to `[^\S\n]` so multi-line stderr like
"invalid token\ncaused by network" no longer eats words across lines
Test coverage added for both regressions (and for tab as a valid
horizontal-whitespace separator).
- P2 (cubic): refresh-failure log now feeds `err.Error()` through
sanitizeStatusMessage before zapping it — same wire-side guard, but
applied to server logs too. Closes the "credentials in journalctl"
hole.
- P2 (cubic): InferenceAgent.status is now optional in the TS type and
the status note treats an undefined status with `models.length > 0`
as healthy, so older API responses don't flash a spurious warning.
Deferred (acknowledged in PR replies, not changed here):
- cubic P2 useInferenceAgents drift — the hook is currently called in
both the dialog and the parent section. Drift is bounded by the
dialog's own `onSuccess` refetch and only visible inside an open
dialog. Lifting state to the parent and prop-drilling is the right
fix but touches multiple files; out of scope for this review round.
* docs(utility): fix stale comments flagged by claude review on PR #1287
Two doc-only items, no code change:
- handlers_integration_test.go: TestIntegration_ConcurrentRefresh
comment said "we don't assert on call count" but the test DOES
assert `host.refreshCalls != N` two lines later. Updated to match
reality (singleflight lives in the real Manager, the stub has none
→ every call must reach the stub).
- utility-agents.spec.ts: the ACP-probe E2E said "backend filters out
non-StatusOK agents", which is the exact behavior this PR removes.
Updated to explain the real reason only Mock appears (KANDEV_MOCK_AGENT=only
registers only `mock-agent`).
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
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. In E2E (KANDEV_MOCK_AGENT=only) only
|
|
// `mock-agent` is registered in the inference-agent registry, so the
|
|
// Agent dropdown shows exactly one option: Mock. (Non-OK agents are no
|
|
// longer filtered out — see #1269 — but in this profile no other agent
|
|
// is registered to begin with.)
|
|
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(
|
|
[],
|
|
);
|
|
});
|
|
|
|
test("non-ok agent renders status note + Refresh re-probes", async ({ testPage }) => {
|
|
// Regression guard for "claude shown in picker but no models, with no
|
|
// explanation". The backend now surfaces probe status so the UI can
|
|
// render an inline note + Refresh button instead of a silently-empty
|
|
// Model picker. Stub both endpoints to drive the state machine.
|
|
const pageErrors: Error[] = [];
|
|
testPage.on("pageerror", (err) => pageErrors.push(err));
|
|
|
|
let refreshCount = 0;
|
|
await testPage.route("**/api/v1/utility/inference-agents", (route) => {
|
|
if (route.request().method() !== "GET") {
|
|
return route.fallback();
|
|
}
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
agents: [
|
|
{
|
|
id: "stub-acp",
|
|
name: "Stub ACP Agent",
|
|
display_name: "Stub",
|
|
status: refreshCount === 0 ? "auth_required" : "ok",
|
|
status_message: refreshCount === 0 ? "please run `stub login`" : "",
|
|
models:
|
|
refreshCount === 0
|
|
? []
|
|
: [{ id: "stub-fast", name: "Stub Fast", description: "", is_default: true }],
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
});
|
|
|
|
await testPage.route("**/api/v1/utility/inference-agents/stub-acp/refresh", (route) => {
|
|
refreshCount += 1;
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
id: "stub-acp",
|
|
name: "Stub ACP Agent",
|
|
display_name: "Stub",
|
|
status: "ok",
|
|
models: [{ id: "stub-fast", name: "Stub Fast", description: "", is_default: true }],
|
|
}),
|
|
});
|
|
});
|
|
|
|
await testPage.goto("/settings/utility-agents");
|
|
await expect(
|
|
testPage.getByRole("heading", { name: "Utility Agents", exact: true }),
|
|
).toBeVisible({ timeout: 15_000 });
|
|
|
|
// Pick Stub from the Agent dropdown so the Default Model row binds
|
|
// selectedAgent to the auth_required entry.
|
|
const agentSelect = testPage
|
|
.locator('div:has(> label:text-is("Agent"))')
|
|
.first()
|
|
.getByRole("combobox");
|
|
await agentSelect.click();
|
|
await testPage.getByRole("option", { name: "Stub", exact: true }).click();
|
|
|
|
// Auth_required status copy renders, Refresh visible.
|
|
const note = testPage.getByTestId("inference-agent-status-note").first();
|
|
await expect(note).toBeVisible();
|
|
await expect(note).toContainText("Sign in to Stub");
|
|
|
|
const refresh = testPage.getByTestId("inference-agent-refresh").first();
|
|
await expect(refresh).toBeVisible();
|
|
await refresh.click();
|
|
|
|
// After Refresh, status note disappears (status:"ok" + 1 model).
|
|
await expect(testPage.getByTestId("inference-agent-status-note").first()).not.toBeVisible();
|
|
expect(refreshCount).toBe(1);
|
|
|
|
expect(pageErrors, `uncaught errors: ${pageErrors.map((e) => e.message).join("; ")}`).toEqual(
|
|
[],
|
|
);
|
|
});
|
|
|
|
test("Configuration Chat Agent section lives here, not on the agents page", async ({
|
|
testPage,
|
|
}) => {
|
|
// Regression guard for the move from /settings/agents to /settings/utility-agents.
|
|
await testPage.goto("/settings/utility-agents");
|
|
await expect(
|
|
testPage.getByRole("heading", { name: "Utility Agents", exact: true }),
|
|
).toBeVisible({ timeout: 15_000 });
|
|
await expect(
|
|
testPage.getByRole("heading", { name: "Configuration Chat Agent", exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
testPage.getByText(
|
|
"Choose which agent profile to use for the Configuration Chat. This agent can manage your workflows, agent profiles, and MCP configuration.",
|
|
),
|
|
).toBeVisible();
|
|
|
|
await testPage.goto("/settings/agents");
|
|
await expect(testPage.getByRole("heading", { name: "Agents", exact: true })).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
await expect(
|
|
testPage.getByRole("heading", { name: "Configuration Chat Agent", exact: true }),
|
|
).toHaveCount(0);
|
|
});
|
|
});
|