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>
111 lines
3.9 KiB
TypeScript
111 lines
3.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { IconRefresh } from "@tabler/icons-react";
|
|
import { Button } from "@kandev/ui/button";
|
|
import type { InferenceAgent, InferenceAgentStatus } from "@/lib/api/domains/utility-api";
|
|
|
|
type Props = {
|
|
/**
|
|
* The agent whose status to render. `null`/`undefined` means the agent
|
|
* selected in the form is not present in the latest `/inference-agents`
|
|
* response — e.g. the user picked Claude but Claude was just uninstalled,
|
|
* or the cache hasn't caught up yet. The fallback note is "agent is no
|
|
* longer available".
|
|
*/
|
|
agent: InferenceAgent | null | undefined;
|
|
/**
|
|
* Fallback agent display name for the not-available case. Without it the
|
|
* note would read "Agent is no longer available" with no identifier.
|
|
*/
|
|
fallbackName?: string;
|
|
/**
|
|
* Re-probe handler. Called when the user clicks Refresh. The parent owns
|
|
* loading state on the InferenceAgent list; this component only manages
|
|
* the in-flight spinner on the button itself so simultaneous clicks
|
|
* don't double-fire.
|
|
*/
|
|
onRefresh: () => Promise<unknown> | void;
|
|
};
|
|
|
|
type Note = { text: string; refreshable: boolean };
|
|
|
|
function noteForStatus(status: InferenceAgentStatus | undefined, name: string): Note {
|
|
switch (status) {
|
|
case "probing":
|
|
return { text: `Setting up ${name}…`, refreshable: true };
|
|
case "auth_required":
|
|
return { text: `Sign in to ${name} to load models.`, refreshable: true };
|
|
case "not_installed":
|
|
return { text: `${name} CLI is not installed on this machine.`, refreshable: true };
|
|
case "not_configured":
|
|
return { text: `${name} is not configured for inference.`, refreshable: false };
|
|
case "failed":
|
|
return { text: `Probe failed for ${name}.`, refreshable: true };
|
|
case "ok":
|
|
default:
|
|
return { text: `${name} advertised no models.`, refreshable: true };
|
|
}
|
|
}
|
|
|
|
function RefreshButton({ onRefresh }: { onRefresh: () => Promise<unknown> | void }) {
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const handleClick = async () => {
|
|
if (refreshing) return;
|
|
setRefreshing(true);
|
|
try {
|
|
await onRefresh();
|
|
} finally {
|
|
setRefreshing(false);
|
|
}
|
|
};
|
|
return (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 px-2 cursor-pointer"
|
|
onClick={handleClick}
|
|
disabled={refreshing}
|
|
data-testid="inference-agent-refresh"
|
|
>
|
|
<IconRefresh className={refreshing ? "h-3 w-3 animate-spin" : "h-3 w-3"} />
|
|
<span className="ml-1">{refreshing ? "Refreshing…" : "Refresh"}</span>
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
// Treat a missing `status` field as healthy when models are present —
|
|
// older backends (or non-OK API consumers) that don't populate `status`
|
|
// should not show a spurious warning. Real probe states ("auth_required",
|
|
// "probing", …) still fall through to noteForStatus. Per cubic review.
|
|
function isAgentHealthy(agent: InferenceAgent | null | undefined): boolean {
|
|
if (!agent || (agent.models?.length ?? 0) === 0) return false;
|
|
return agent.status === "ok" || agent.status === undefined;
|
|
}
|
|
|
|
export function InferenceAgentStatusNote({ agent, fallbackName, onRefresh }: Props) {
|
|
if (isAgentHealthy(agent)) {
|
|
return null;
|
|
}
|
|
|
|
const name = agent?.display_name ?? fallbackName ?? "this agent";
|
|
const note: Note = agent
|
|
? noteForStatus(agent.status, name)
|
|
: { text: `${name} is no longer available.`, refreshable: true };
|
|
const detail = agent?.status_message?.trim();
|
|
|
|
return (
|
|
<div
|
|
className="flex items-start justify-between gap-2 text-xs text-muted-foreground"
|
|
data-testid="inference-agent-status-note"
|
|
>
|
|
<div className="space-y-0.5 min-w-0">
|
|
<p>{note.text}</p>
|
|
{detail && <p className="text-[11px] opacity-80 truncate">{detail}</p>}
|
|
</div>
|
|
{note.refreshable && <RefreshButton onRefresh={onRefresh} />}
|
|
</div>
|
|
);
|
|
}
|