Files
kandev/apps/web/components/settings/utility-sections.tsx
T
João Salavisa 48a621a566 fix(utility): surface inference-agent probe status + add refresh endpoint (#1287)
* 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>
2026-06-07 12:38:28 +01:00

278 lines
8.6 KiB
TypeScript

"use client";
import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Label } from "@kandev/ui/label";
import { Separator } from "@kandev/ui/separator";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@kandev/ui/select";
import { ModelCombobox } from "@/components/settings/model-combobox";
import { InferenceAgentStatusNote } from "@/components/settings/inference-agent-status";
import type { UtilityAgent, InferenceAgent } from "@/lib/api/domains/utility-api";
const USE_DEFAULT = "__USE_DEFAULT__";
export type ModelOption = { value: string; label: string; agentName: string; modelName: string };
type ModelGroup = { agentName: string; models: ModelOption[] };
function groupModelsByAgent(models: ModelOption[]): ModelGroup[] {
const map = new Map<string, ModelOption[]>();
for (const m of models) {
const list = map.get(m.agentName);
if (list) list.push(m);
else map.set(m.agentName, [m]);
}
return Array.from(map, ([agentName, items]) => ({ agentName, models: items }));
}
// Default model selector section
type DefaultModelSectionProps = {
inferenceAgents: InferenceAgent[];
defaultAgentId: string;
defaultModel: string;
onDefaultChange: (agentId: string, model: string) => void;
onRefreshAgent: (agentId: string) => Promise<unknown> | void;
};
export function DefaultModelSection({
inferenceAgents,
defaultAgentId,
defaultModel,
onDefaultChange,
onRefreshAgent,
}: 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">
<div>
<h3 className="text-base font-medium">Default utility agent model</h3>
<p className="text-sm text-muted-foreground">
Select the default model used by all built-in utility actions.
</p>
</div>
<div className="flex gap-2">
<div className="w-[180px]">
<Label className="text-xs text-muted-foreground mb-1 block">Agent</Label>
<Select value={defaultAgentId} onValueChange={(v) => onDefaultChange(v, "")}>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder="Select agent..." />
</SelectTrigger>
<SelectContent>
{inferenceAgents.map((ia) => (
<SelectItem key={ia.id} value={ia.id}>
{ia.display_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-[220px]">
<Label className="text-xs text-muted-foreground mb-1 block">Model</Label>
<ModelCombobox
value={defaultModel}
onChange={(v) => onDefaultChange(defaultAgentId, v)}
models={modelOptions}
currentModelId={currentModelId}
placeholder="Select model..."
disabled={!defaultAgentId}
/>
</div>
</div>
{defaultAgentId && (
<InferenceAgentStatusNote
agent={selectedAgent}
fallbackName={defaultAgentId}
onRefresh={() => onRefreshAgent(defaultAgentId)}
/>
)}
</div>
);
}
// Builtin action row
type BuiltinActionRowProps = {
agent: UtilityAgent;
allModels: ModelOption[];
defaultLabel: string;
onModelChange: (agent: UtilityAgent, value: string) => void;
onEdit: (agent: UtilityAgent) => void;
};
export function BuiltinActionRow({
agent,
allModels,
defaultLabel,
onModelChange,
onEdit,
}: BuiltinActionRowProps) {
const currentValue =
agent.agent_id && agent.model ? `${agent.agent_id}|${agent.model}` : USE_DEFAULT;
return (
<div className="flex items-center gap-4 py-2 px-2 rounded hover:bg-muted/50">
<div className="w-[420px] shrink-0">
<div className="text-sm font-medium">{agent.name}</div>
<p className="text-xs text-muted-foreground truncate">{agent.description}</p>
</div>
<Select value={currentValue} onValueChange={(v) => onModelChange(agent, v)}>
<SelectTrigger className="w-[240px] cursor-pointer">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value={USE_DEFAULT}>{defaultLabel}</SelectItem>
</SelectGroup>
{groupModelsByAgent(allModels).map((group) => (
<SelectGroup key={group.agentName}>
<SelectSeparator />
<SelectLabel>{group.agentName}</SelectLabel>
{group.models.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.modelName}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(agent)}
className="h-7 w-7 p-0 cursor-pointer text-muted-foreground hover:text-foreground"
>
<IconPencil className="h-3.5 w-3.5" />
</Button>
</div>
);
}
// Custom agent row
type CustomAgentRowProps = {
agent: UtilityAgent;
onEdit: (agent: UtilityAgent) => void;
onDelete: (agent: UtilityAgent) => void;
};
export function CustomAgentRow({ agent, onEdit, onDelete }: CustomAgentRowProps) {
return (
<div className="flex items-center justify-between py-3 px-3 rounded hover:bg-muted/50">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{agent.name}</div>
<p className="text-xs text-muted-foreground truncate">{agent.description}</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{agent.model || "Not configured"}</span>
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(agent)}
className="h-7 w-7 p-0 cursor-pointer text-muted-foreground hover:text-foreground"
>
<IconPencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(agent)}
className="h-7 w-7 p-0 cursor-pointer text-muted-foreground hover:text-destructive"
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}
// Per-action overrides section
type PerActionOverridesSectionProps = {
builtins: UtilityAgent[];
allModels: ModelOption[];
defaultModel: string;
onModelChange: (agent: UtilityAgent, value: string) => void;
onEdit: (agent: UtilityAgent) => void;
};
export function PerActionOverridesSection({
builtins,
allModels,
defaultModel,
onModelChange,
onEdit,
}: PerActionOverridesSectionProps) {
if (builtins.length === 0) return null;
const defaultLabel = defaultModel ? `Default (${defaultModel})` : "Default";
return (
<div className="space-y-2">
<Separator />
<h3 className="text-base font-medium pt-2">Actions</h3>
<div className="space-y-0">
{builtins.map((agent) => (
<BuiltinActionRow
key={agent.id}
agent={agent}
allModels={allModels}
defaultLabel={defaultLabel}
onModelChange={onModelChange}
onEdit={onEdit}
/>
))}
</div>
</div>
);
}
// Custom agents section
type CustomAgentsSectionProps = {
agents: UtilityAgent[];
onAdd: () => void;
onEdit: (agent: UtilityAgent) => void;
onDelete: (agent: UtilityAgent) => void;
};
export function CustomAgentsSection({ agents, onAdd, onEdit, onDelete }: CustomAgentsSectionProps) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-medium">Custom utility agents</h3>
<p className="text-sm text-muted-foreground">
Create your own utility agents with custom prompts.
</p>
</div>
<Button onClick={onAdd} size="sm" className="cursor-pointer">
<IconPlus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
{agents.length === 0 && (
<p className="text-sm text-muted-foreground py-4">No custom utility agents.</p>
)}
{agents.length > 0 && (
<div className="space-y-2">
{agents.map((agent) => (
<CustomAgentRow key={agent.id} agent={agent} onEdit={onEdit} onDelete={onDelete} />
))}
</div>
)}
</div>
);
}
// Export the USE_DEFAULT constant
export { USE_DEFAULT };