Files
kandev/apps/web/lib/api/domains/utility-api.ts
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

200 lines
5.4 KiB
TypeScript

import { fetchJson, type ApiRequestOptions } from "../client";
// Types
export type UtilityAgent = {
id: string;
name: string;
description: string;
prompt: string;
agent_id: string;
model: string;
builtin: boolean;
enabled: boolean;
created_at: string;
updated_at: string;
};
export type UtilityAgentCall = {
id: string;
utility_id: string;
session_id: string;
resolved_prompt: string;
response: string;
model: string;
prompt_tokens: number;
response_tokens: number;
duration_ms: number;
status: string;
error_message: string;
created_at: string;
completed_at?: string;
};
export type TemplateVariable = {
name: string;
description: string;
example: string;
category: string;
};
export type InferenceModel = {
id: string;
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>;
};
/**
* Probe outcome for the host-utility agentctl instance backing this agent.
* Mirrors `hostutility.Status` (`apps/backend/internal/agent/hostutility/types.go`).
* The settings page renders an inline status note + Refresh button when
* `status !== "ok"` or `models` is empty, instead of a silently-disabled
* Model picker.
*/
export type InferenceAgentStatus =
| "ok"
| "probing"
| "auth_required"
| "not_installed"
| "failed"
| "not_configured";
export type InferenceAgent = {
id: string;
name: string;
display_name: string;
models: InferenceModel[];
// Optional so older backends (or non-OK API consumers) that omit the
// field decode without forcing a default; the UI treats missing status
// as healthy when models are present.
status?: InferenceAgentStatus;
status_message?: string;
};
export type ExecutePromptRequest = {
utility_agent_id: string;
session_id?: string;
git_diff?: string;
commit_log?: string;
changed_files?: string;
diff_summary?: string;
branch_name?: string;
base_branch?: string;
task_title?: string;
task_description?: string;
user_prompt?: string;
conversation_history?: string;
};
export type ExecutePromptResponse = {
success: boolean;
call_id?: string;
response?: string;
model?: string;
prompt_tokens?: number;
response_tokens?: number;
duration_ms?: number;
error?: string;
};
// API Functions
export async function listUtilityAgents(
options?: ApiRequestOptions,
): Promise<{ agents: UtilityAgent[] }> {
return fetchJson<{ agents: UtilityAgent[] }>("/api/v1/utility/agents", options);
}
export async function getUtilityAgent(
id: string,
options?: ApiRequestOptions,
): Promise<UtilityAgent> {
return fetchJson<UtilityAgent>(`/api/v1/utility/agents/${id}`, options);
}
export async function createUtilityAgent(
data: Partial<UtilityAgent>,
options?: ApiRequestOptions,
): Promise<UtilityAgent> {
return fetchJson<UtilityAgent>("/api/v1/utility/agents", {
...options,
init: { method: "POST", body: JSON.stringify(data), ...(options?.init ?? {}) },
});
}
export async function updateUtilityAgent(
id: string,
data: Partial<UtilityAgent>,
options?: ApiRequestOptions,
): Promise<UtilityAgent> {
return fetchJson<UtilityAgent>(`/api/v1/utility/agents/${id}`, {
...options,
init: { method: "PATCH", body: JSON.stringify(data), ...(options?.init ?? {}) },
});
}
export async function deleteUtilityAgent(id: string, options?: ApiRequestOptions): Promise<void> {
await fetchJson<{ success: boolean }>(`/api/v1/utility/agents/${id}`, {
...options,
init: { method: "DELETE", ...(options?.init ?? {}) },
});
}
export async function getTemplateVariables(
options?: ApiRequestOptions,
): Promise<{ variables: TemplateVariable[] }> {
return fetchJson<{ variables: TemplateVariable[] }>(
"/api/v1/utility/template-variables",
options,
);
}
export async function listInferenceAgents(
options?: ApiRequestOptions,
): Promise<{ agents: InferenceAgent[] }> {
return fetchJson<{ agents: InferenceAgent[] }>("/api/v1/utility/inference-agents", options);
}
/**
* Re-probe a single inference agent and return its updated capabilities.
* Used by the settings-page Refresh button so the user can recover from a
* transient probe failure (sign-in race, agent not yet installed at boot,
* network blip) without restarting kandev.
*/
export async function refreshInferenceAgent(
id: string,
options?: ApiRequestOptions,
): Promise<InferenceAgent> {
return fetchJson<InferenceAgent>(`/api/v1/utility/inference-agents/${id}/refresh`, {
...options,
init: { method: "POST", ...(options?.init ?? {}) },
});
}
export async function executeUtilityPrompt(
req: ExecutePromptRequest,
options?: ApiRequestOptions,
): Promise<ExecutePromptResponse> {
return fetchJson<ExecutePromptResponse>("/api/v1/utility/execute", {
...options,
init: { method: "POST", body: JSON.stringify(req), ...(options?.init ?? {}) },
});
}
export async function listUtilityCalls(
utilityId: string,
limit = 50,
options?: ApiRequestOptions,
): Promise<{ calls: UtilityAgentCall[] }> {
return fetchJson<{ calls: UtilityAgentCall[] }>(
`/api/v1/utility/agents/${utilityId}/calls?limit=${limit}`,
options,
);
}