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>
296 lines
8.6 KiB
TypeScript
296 lines
8.6 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useMemo } from "react";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@kandev/ui/dialog";
|
|
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,
|
|
updateUtilityAgent,
|
|
getTemplateVariables,
|
|
listInferenceAgents,
|
|
type TemplateVariable,
|
|
type InferenceAgent,
|
|
type InferenceModel,
|
|
} from "@/lib/api/domains/utility-api";
|
|
import { InferenceAgentStatusNote } from "./inference-agent-status";
|
|
import { useInferenceAgents } from "./use-inference-agents";
|
|
import { ScriptEditor } from "./profile-edit/script-editor";
|
|
import type { ScriptPlaceholder } from "./profile-edit/script-editor-completions";
|
|
|
|
type Props = {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
agent: UtilityAgent | null;
|
|
onSuccess: () => void;
|
|
};
|
|
|
|
type FormState = {
|
|
name: string;
|
|
description: string;
|
|
prompt: string;
|
|
agent_id: string;
|
|
model: string;
|
|
};
|
|
|
|
const defaultFormState: FormState = {
|
|
name: "",
|
|
description: "",
|
|
prompt: "",
|
|
agent_id: "claude-acp",
|
|
model: "",
|
|
};
|
|
|
|
function toScriptPlaceholders(variables: TemplateVariable[]): ScriptPlaceholder[] {
|
|
return variables.map((v) => ({
|
|
key: v.name,
|
|
description: v.description,
|
|
example: v.example,
|
|
executor_types: [],
|
|
}));
|
|
}
|
|
|
|
type AgentModelSelectProps = {
|
|
agentId: string;
|
|
model: string;
|
|
inferenceAgents: InferenceAgent[];
|
|
selectedAgent: InferenceAgent | undefined;
|
|
availableModels: InferenceModel[];
|
|
onAgentChange: (agentId: string) => void;
|
|
onModelChange: (model: string) => void;
|
|
onRefresh: () => Promise<unknown> | void;
|
|
};
|
|
|
|
function AgentModelSelect({
|
|
agentId,
|
|
model,
|
|
inferenceAgents,
|
|
selectedAgent,
|
|
availableModels,
|
|
onAgentChange,
|
|
onModelChange,
|
|
onRefresh,
|
|
}: AgentModelSelectProps) {
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Agent</Label>
|
|
<Select value={agentId} onValueChange={onAgentChange}>
|
|
<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="space-y-2">
|
|
<Label>Model</Label>
|
|
<ModelCombobox
|
|
value={model}
|
|
onChange={onModelChange}
|
|
models={availableModels}
|
|
currentModelId={availableModels.find((m) => m.is_default)?.id}
|
|
placeholder="Select model..."
|
|
disabled={availableModels.length === 0}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<InferenceAgentStatusNote
|
|
agent={selectedAgent}
|
|
fallbackName={agentId}
|
|
onRefresh={onRefresh}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type UtilityAgentFormProps = {
|
|
form: FormState;
|
|
setForm: React.Dispatch<React.SetStateAction<FormState>>;
|
|
isBuiltin: boolean;
|
|
inferenceAgents: InferenceAgent[];
|
|
selectedAgent: InferenceAgent | undefined;
|
|
availableModels: InferenceModel[];
|
|
placeholders: ScriptPlaceholder[];
|
|
onRefreshAgent: () => Promise<unknown> | void;
|
|
};
|
|
|
|
function UtilityAgentForm({
|
|
form,
|
|
setForm,
|
|
isBuiltin,
|
|
inferenceAgents,
|
|
selectedAgent,
|
|
availableModels,
|
|
placeholders,
|
|
onRefreshAgent,
|
|
}: UtilityAgentFormProps) {
|
|
return (
|
|
<div className="space-y-4 py-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">Name</Label>
|
|
<Input
|
|
id="name"
|
|
value={form.name}
|
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
|
placeholder="e.g., commit-message"
|
|
disabled={isBuiltin}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="description">Description</Label>
|
|
<Input
|
|
id="description"
|
|
value={form.description}
|
|
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
|
placeholder="Brief description of what this agent does"
|
|
/>
|
|
</div>
|
|
<AgentModelSelect
|
|
agentId={form.agent_id}
|
|
model={form.model}
|
|
inferenceAgents={inferenceAgents}
|
|
selectedAgent={selectedAgent}
|
|
availableModels={availableModels}
|
|
onAgentChange={(v) => setForm((f) => ({ ...f, agent_id: v, model: "" }))}
|
|
onModelChange={(v) => setForm((f) => ({ ...f, model: v }))}
|
|
onRefresh={onRefreshAgent}
|
|
/>
|
|
<div className="space-y-2">
|
|
<Label>Prompt Template</Label>
|
|
<div className="border rounded-md overflow-hidden">
|
|
<ScriptEditor
|
|
value={form.prompt}
|
|
onChange={(v) => setForm((f) => ({ ...f, prompt: v }))}
|
|
language="plaintext"
|
|
height="200px"
|
|
placeholders={placeholders}
|
|
lineNumbers="off"
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Type {"{{"} to see available variables with autocomplete
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function UtilityAgentDialog({ open, onOpenChange, agent, onSuccess }: Props) {
|
|
const [form, setForm] = useState<FormState>(defaultFormState);
|
|
const [saving, setSaving] = useState(false);
|
|
const [placeholders, setPlaceholders] = useState<ScriptPlaceholder[]>([]);
|
|
const { inferenceAgents, setInferenceAgents, refreshAgent } = useInferenceAgents();
|
|
const isEdit = Boolean(agent);
|
|
|
|
// Fetch template variables and inference agents
|
|
useEffect(() => {
|
|
getTemplateVariables()
|
|
.then(({ variables }) => setPlaceholders(toScriptPlaceholders(variables)))
|
|
.catch(() => setPlaceholders([]));
|
|
|
|
listInferenceAgents()
|
|
.then(({ agents }) => setInferenceAgents(agents))
|
|
.catch(() => setInferenceAgents([]));
|
|
}, [setInferenceAgents]);
|
|
|
|
const selectedAgent = useMemo(
|
|
() => inferenceAgents.find((a) => a.id === form.agent_id),
|
|
[inferenceAgents, form.agent_id],
|
|
);
|
|
const availableModels = selectedAgent?.models ?? [];
|
|
const refreshCurrentAgent = () => refreshAgent(form.agent_id);
|
|
|
|
// Auto-select default model when agent changes
|
|
useEffect(() => {
|
|
if (selectedAgent && !form.model) {
|
|
const defaultModel = (selectedAgent.models ?? []).find((m) => m.is_default);
|
|
if (defaultModel) {
|
|
setForm((f) => ({ ...f, model: defaultModel.id }));
|
|
}
|
|
}
|
|
}, [selectedAgent, form.model]);
|
|
|
|
useEffect(() => {
|
|
if (agent) {
|
|
setForm({
|
|
name: agent.name,
|
|
description: agent.description,
|
|
prompt: agent.prompt,
|
|
agent_id: agent.agent_id || "claude-acp",
|
|
model: agent.model || "",
|
|
});
|
|
} else {
|
|
setForm(defaultFormState);
|
|
}
|
|
}, [agent, open]);
|
|
|
|
const handleSubmit = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const data = {
|
|
name: form.name,
|
|
description: form.description,
|
|
prompt: form.prompt,
|
|
agent_id: form.agent_id,
|
|
model: form.model,
|
|
};
|
|
|
|
if (isEdit && agent) {
|
|
await updateUtilityAgent(agent.id, data);
|
|
} else {
|
|
await createUtilityAgent(data);
|
|
}
|
|
onSuccess();
|
|
} catch (error) {
|
|
console.error("Failed to save agent:", error);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const dialogTitle = isEdit ? "Edit Utility Agent" : "Create Utility Agent";
|
|
const getSubmitLabel = () => {
|
|
if (saving) return "Saving...";
|
|
return isEdit ? "Save Changes" : "Create Agent";
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
|
</DialogHeader>
|
|
<UtilityAgentForm
|
|
form={form}
|
|
setForm={setForm}
|
|
isBuiltin={agent?.builtin ?? false}
|
|
inferenceAgents={inferenceAgents}
|
|
selectedAgent={selectedAgent}
|
|
availableModels={availableModels}
|
|
placeholders={placeholders}
|
|
onRefreshAgent={refreshCurrentAgent}
|
|
/>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)} className="cursor-pointer">
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleSubmit} disabled={saving || !form.name} className="cursor-pointer">
|
|
{getSubmitLabel()}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|