feat: agent variants — migration, API, service integration, frontend, tests

- Migration 027: agent_variants table with single-active enforcement,
  variant_id column on agent_performance_log
- API: full CRUD, clone from agent/variant, activate/deactivate,
  per-variant performance metrics and history endpoints
- Services: extractor, event classifier, thesis rewriter all wired
  to AgentConfigResolver with variant override support
- Frontend: variant list, comparison view, create/edit/clone forms,
  activate/delete actions on Agents page
- Tests: API tests + 5 property-based tests (single-active invariant,
  clone preservation, config resolution, slug determinism, update idempotence)
- Spec files for agent-variants feature
This commit is contained in:
Celes Renata
2026-04-17 05:15:42 +00:00
parent 734bf001a7
commit 7c23c044d7
14 changed files with 3118 additions and 120 deletions
+724 -3
View File
@@ -1,9 +1,9 @@
import { useState } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiPut, apiDelete } from '../api/client';
import { apiGet, apiPost, apiPut, apiDelete, ApiError } from '../api/client';
import { Card, LoadingSpinner, StatusBadge } from '../components/ui';
import {
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend,
} from 'recharts';
// ---------------------------------------------------------------------------
@@ -52,6 +52,29 @@ interface PerfHistoryPoint {
avg_confidence: number;
}
interface AgentVariant {
id: string;
agent_id: string;
variant_name: string;
variant_slug: string;
description: string;
model_provider: string;
model_name: string;
system_prompt: string;
user_prompt_template: string;
prompt_version: string;
temperature: number;
max_tokens: number;
context_window: number;
input_token_limit: number;
token_budget: number;
timeout_seconds: number;
max_retries: number;
is_active: boolean;
created_at: string;
updated_at: string;
}
// ---------------------------------------------------------------------------
// Hooks
// ---------------------------------------------------------------------------
@@ -79,6 +102,82 @@ function useAgentPerfHistory(agentId: string | undefined, hours = 24) {
});
}
// -- Variant query hooks --
function useAgentVariants(agentId: string | undefined) {
return useQuery<AgentVariant[]>({
queryKey: ['agent-variants', agentId],
queryFn: () => apiGet<AgentVariant[]>('query', `/api/agents/${agentId}/variants`),
enabled: !!agentId,
});
}
export function useVariantPerformance(agentId: string, variantId: string, hours = 24) {
return useQuery<AgentPerformance>({
queryKey: ['variant-performance', agentId, variantId, hours],
queryFn: () => apiGet<AgentPerformance>('query', `/api/agents/${agentId}/variants/${variantId}/performance?hours=${hours}`),
enabled: !!agentId && !!variantId,
});
}
export function useVariantPerfHistory(agentId: string, variantId: string, hours = 24) {
return useQuery<PerfHistoryPoint[]>({
queryKey: ['variant-perf-history', agentId, variantId, hours],
queryFn: () => apiGet<PerfHistoryPoint[]>('query', `/api/agents/${agentId}/variants/${variantId}/performance/history?hours=${hours}`),
enabled: !!agentId && !!variantId,
});
}
// -- Variant mutation hooks --
function useCloneAgentAsVariant(agentId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: Record<string, unknown>) => apiPost<AgentVariant>('query', `/api/agents/${agentId}/clone`, body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['agent-variants'] }); },
});
}
export function useCreateVariant(agentId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: Record<string, unknown>) => apiPost<AgentVariant>('query', `/api/agents/${agentId}/variants`, body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['agent-variants'] }); },
});
}
function useUpdateVariant(agentId: string, variantId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: Record<string, unknown>) => apiPut<AgentVariant>('query', `/api/agents/${agentId}/variants/${variantId}`, body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['agent-variants'] }); },
});
}
function useDeleteVariant(agentId: string, variantId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => apiDelete<unknown>('query', `/api/agents/${agentId}/variants/${variantId}`),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['agent-variants'] }); },
});
}
function useActivateVariant(agentId: string, variantId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => apiPost<AgentVariant>('query', `/api/agents/${agentId}/variants/${variantId}/activate`, {}),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['agent-variants'] }); },
});
}
export function useDeactivateVariants(agentId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => apiPost<unknown>('query', `/api/agents/${agentId}/variants/deactivate`, {}),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['agent-variants'] }); },
});
}
// ---------------------------------------------------------------------------
// Page Component
// ---------------------------------------------------------------------------
@@ -162,6 +261,36 @@ function AgentDetail({ agent, onEdit, onDeleted }: { agent: Agent; onEdit: () =>
const qc = useQueryClient();
const { data: perf } = useAgentPerformance(agent.id);
const { data: history } = useAgentPerfHistory(agent.id);
const { data: variants } = useAgentVariants(agent.id);
// Variant UI state
const [variantView, setVariantView] = useState<
| { mode: 'list' }
| { mode: 'clone-agent' }
| { mode: 'clone-variant'; source: AgentVariant }
| { mode: 'edit-variant'; variant: AgentVariant }
| { mode: 'delete-confirm'; variant: AgentVariant }
>({ mode: 'list' });
// Comparison selection state
const [selectedVariantIds, setSelectedVariantIds] = useState<Set<string>>(new Set());
// Clear selection when variants change (e.g., after activation, deletion)
useEffect(() => {
setSelectedVariantIds(new Set());
}, [variants]);
const toggleVariantSelection = useCallback((variantId: string) => {
setSelectedVariantIds((prev) => {
const next = new Set(prev);
if (next.has(variantId)) {
next.delete(variantId);
} else {
next.add(variantId);
}
return next;
});
}, []);
const deleteMut = useMutation({
mutationFn: () => apiDelete<unknown>('query', `/api/agents/${agent.id}`),
@@ -188,6 +317,7 @@ function AgentDetail({ agent, onEdit, onDeleted }: { agent: Agent; onEdit: () =>
</span>
</div>
<div className="flex gap-2">
<button onClick={() => setVariantView({ mode: 'clone-agent' })} className="rounded-md border border-brand-500/50 px-3 py-1.5 text-sm font-medium text-brand-300 hover:bg-brand-600/20">Clone as Variant</button>
<button onClick={onEdit} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700">Edit</button>
{agent.source === 'user' && (
<button onClick={() => deleteMut.mutate()} className="rounded-md border border-red-700/50 px-3 py-1.5 text-sm text-red-400 hover:bg-red-900/20">Delete</button>
@@ -258,10 +388,601 @@ function AgentDetail({ agent, onEdit, onDeleted }: { agent: Agent; onEdit: () =>
</ResponsiveContainer>
</Card>
)}
{/* Variant Section */}
{variantView.mode === 'clone-agent' && (
<VariantCloneForm
agentId={agent.id}
source={{
model_provider: agent.model_provider,
model_name: agent.model_name,
system_prompt: agent.system_prompt,
user_prompt_template: agent.user_prompt_template,
prompt_version: agent.prompt_version,
temperature: agent.temperature,
max_tokens: agent.max_tokens,
context_window: 0,
input_token_limit: 0,
token_budget: 0,
timeout_seconds: agent.timeout_seconds,
max_retries: agent.max_retries,
}}
sourceType="agent"
onDone={() => setVariantView({ mode: 'list' })}
/>
)}
{variantView.mode === 'clone-variant' && (
<VariantCloneForm
agentId={agent.id}
source={variantView.source}
sourceType="variant"
sourceVariantId={variantView.source.id}
onDone={() => setVariantView({ mode: 'list' })}
/>
)}
{variantView.mode === 'edit-variant' && (
<VariantEditForm
agentId={agent.id}
variant={variantView.variant}
onDone={() => setVariantView({ mode: 'list' })}
/>
)}
{variantView.mode === 'delete-confirm' && (
<DeleteVariantDialog
agentId={agent.id}
variant={variantView.variant}
onDone={() => setVariantView({ mode: 'list' })}
/>
)}
<VariantList
agentId={agent.id}
variants={variants ?? []}
selectedIds={selectedVariantIds}
onToggleSelect={toggleVariantSelection}
onClone={(v) => setVariantView({ mode: 'clone-variant', source: v })}
onEdit={(v) => setVariantView({ mode: 'edit-variant', variant: v })}
onDelete={(v) => setVariantView({ mode: 'delete-confirm', variant: v })}
/>
{selectedVariantIds.size >= 2 && (
<VariantCompare
agentId={agent.id}
variants={(variants ?? []).filter((v) => selectedVariantIds.has(v.id))}
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Variant List
// ---------------------------------------------------------------------------
function VariantList({ agentId, variants, selectedIds, onToggleSelect, onClone, onEdit, onDelete }: {
agentId: string;
variants: AgentVariant[];
selectedIds: Set<string>;
onToggleSelect: (id: string) => void;
onClone: (v: AgentVariant) => void;
onEdit: (v: AgentVariant) => void;
onDelete: (v: AgentVariant) => void;
}) {
return (
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Variants ({variants.length})</h2>
{variants.length === 0 ? (
<p className="text-xs text-gray-500">No variants yet. Clone this agent to create one.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-700 text-left text-xs text-gray-500">
<th className="pb-2 pr-2 w-8">
<span className="sr-only">Select</span>
</th>
<th className="pb-2 pr-4">Name</th>
<th className="pb-2 pr-4">Model</th>
<th className="pb-2 pr-4">Status</th>
<th className="pb-2 pr-4">Created</th>
<th className="pb-2 text-right">Actions</th>
</tr>
</thead>
<tbody>
{variants.map((v) => (
<VariantRow
key={v.id}
agentId={agentId}
variant={v}
selected={selectedIds.has(v.id)}
onToggleSelect={onToggleSelect}
onClone={onClone}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</tbody>
</table>
{selectedIds.size > 0 && selectedIds.size < 2 && (
<p className="mt-2 text-[10px] text-gray-500">Select at least 2 variants to compare.</p>
)}
</div>
)}
</Card>
);
}
function VariantRow({ agentId, variant, selected, onToggleSelect, onClone, onEdit, onDelete }: {
agentId: string;
variant: AgentVariant;
selected: boolean;
onToggleSelect: (id: string) => void;
onClone: (v: AgentVariant) => void;
onEdit: (v: AgentVariant) => void;
onDelete: (v: AgentVariant) => void;
}) {
const activateMut = useActivateVariant(agentId, variant.id);
return (
<tr className={`border-b border-surface-800 ${variant.is_active ? 'bg-green-900/10' : ''}`}>
<td className="py-2 pr-2">
<input
type="checkbox"
checked={selected}
onChange={() => onToggleSelect(variant.id)}
className="rounded border-surface-600 bg-surface-950 text-brand-500 focus:ring-brand-500 focus:ring-offset-0 h-3.5 w-3.5"
/>
</td>
<td className="py-2 pr-4">
<span className="font-medium text-gray-200">{variant.variant_name}</span>
{variant.description && <p className="text-[10px] text-gray-500 truncate max-w-[200px]">{variant.description}</p>}
</td>
<td className="py-2 pr-4 font-mono text-xs text-gray-400">{variant.model_name}</td>
<td className="py-2 pr-4">
{variant.is_active ? <StatusBadge status="active" /> : <span className="text-xs text-gray-600">inactive</span>}
</td>
<td className="py-2 pr-4 text-xs text-gray-500">{new Date(variant.created_at).toLocaleDateString()}</td>
<td className="py-2 text-right">
<div className="flex justify-end gap-1">
<button onClick={() => onEdit(variant)} className="rounded px-2 py-0.5 text-[10px] text-gray-400 hover:bg-surface-800 hover:text-gray-200">Edit</button>
<button onClick={() => onClone(variant)} className="rounded px-2 py-0.5 text-[10px] text-gray-400 hover:bg-surface-800 hover:text-gray-200">Clone</button>
<button onClick={() => onDelete(variant)} className="rounded px-2 py-0.5 text-[10px] text-red-400 hover:bg-red-900/20">Delete</button>
<button
onClick={() => activateMut.mutate()}
disabled={variant.is_active || activateMut.isPending}
className="rounded px-2 py-0.5 text-[10px] font-medium text-brand-400 hover:bg-brand-600/20 disabled:opacity-30 disabled:cursor-not-allowed"
>
{activateMut.isPending ? '…' : 'Activate'}
</button>
</div>
</td>
</tr>
);
}
// ---------------------------------------------------------------------------
// Variant Comparison View
// ---------------------------------------------------------------------------
const COMPARE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'];
function VariantCompareColumn({ agentId, variant, color }: {
agentId: string;
variant: AgentVariant;
color: string;
}) {
const { data: perf } = useVariantPerformance(agentId, variant.id);
const activateMut = useActivateVariant(agentId, variant.id);
return (
<td className="px-3 py-2 text-center text-sm">
<div className="mb-2">
<span className="font-medium text-gray-200">{variant.variant_name}</span>
<span className="ml-2 text-[10px] font-mono text-gray-500">{variant.model_name}</span>
{variant.is_active && <StatusBadge status="active" />}
</div>
<div className="space-y-1.5 text-xs">
<div>
<span className="text-gray-500">Success Rate</span>
<div className={`font-bold ${perf?.success_rate != null && perf.success_rate >= 0.95 ? 'text-green-400' : 'text-yellow-400'}`}>
{perf?.success_rate != null ? `${(perf.success_rate * 100).toFixed(1)}%` : '—'}
</div>
</div>
<div>
<span className="text-gray-500">Avg Latency</span>
<div className="text-gray-200">{perf?.avg_duration_ms != null ? `${Math.round(perf.avg_duration_ms)}ms` : '—'}</div>
</div>
<div>
<span className="text-gray-500">P95 Latency</span>
<div className="text-gray-200">{perf?.p95_duration_ms != null ? `${Math.round(perf.p95_duration_ms)}ms` : '—'}</div>
</div>
<div>
<span className="text-gray-500">Avg Confidence</span>
<div className="text-gray-200">{perf?.avg_confidence != null ? `${(perf.avg_confidence * 100).toFixed(0)}%` : '—'}</div>
</div>
<div>
<span className="text-gray-500">Total Tokens</span>
<div className="text-gray-200">
{perf?.total_input_tokens != null || perf?.total_output_tokens != null
? ((perf.total_input_tokens ?? 0) + (perf.total_output_tokens ?? 0)).toLocaleString()
: '—'}
</div>
</div>
</div>
{!variant.is_active && (
<button
onClick={() => activateMut.mutate()}
disabled={activateMut.isPending}
className="mt-3 rounded-md px-3 py-1 text-[10px] font-medium text-white hover:opacity-90 disabled:opacity-30"
style={{ backgroundColor: color }}
>
{activateMut.isPending ? 'Activating…' : 'Activate'}
</button>
)}
</td>
);
}
function VariantCompareChart({ agentId, variants }: { agentId: string; variants: AgentVariant[] }) {
// Fetch history for each selected variant
const historyQueries = variants.map((v) =>
// eslint-disable-next-line react-hooks/rules-of-hooks
useVariantPerfHistory(agentId, v.id)
);
// Merge all history data into a unified time-series keyed by hour
const hourMap = new Map<string, Record<string, number | string>>();
variants.forEach((v, idx) => {
const history = historyQueries[idx].data ?? [];
for (const pt of history) {
const hourKey = new Date(pt.hour).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (!hourMap.has(hourKey)) {
hourMap.set(hourKey, { hour: hourKey });
}
const entry = hourMap.get(hourKey)!;
entry[`sr_${v.id}`] = pt.invocations > 0 ? Math.round((pt.successes / pt.invocations) * 100) : 0;
entry[`lat_${v.id}`] = Math.round(pt.avg_duration_ms);
}
});
const chartData = Array.from(hourMap.values());
if (chartData.length < 2) return null;
return (
<div className="mt-4">
<h3 className="mb-2 text-xs font-medium text-gray-500">Success Rate Over Time</h3>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="hour" tick={{ fill: '#6b7280', fontSize: 10 }} />
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
<Legend />
{variants.map((v, idx) => (
<Line
key={v.id}
type="monotone"
dataKey={`sr_${v.id}`}
name={v.variant_name}
stroke={COMPARE_COLORS[idx % COMPARE_COLORS.length]}
strokeWidth={2}
dot={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
);
}
function VariantCompare({ agentId, variants }: { agentId: string; variants: AgentVariant[] }) {
return (
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Variant Comparison ({variants.length} selected)</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-700">
<th className="pb-2 pr-4 text-left text-xs text-gray-500">Metric</th>
{variants.map((v, idx) => (
<th key={v.id} className="pb-2 px-3 text-center text-xs" style={{ color: COMPARE_COLORS[idx % COMPARE_COLORS.length] }}>
{v.variant_name}
</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td className="py-1 pr-4 text-xs text-gray-500">Metrics</td>
{variants.map((v, idx) => (
<VariantCompareColumn
key={v.id}
agentId={agentId}
variant={v}
color={COMPARE_COLORS[idx % COMPARE_COLORS.length]}
/>
))}
</tr>
</tbody>
</table>
</div>
<VariantCompareChart agentId={agentId} variants={variants} />
</Card>
);
}
// ---------------------------------------------------------------------------
// Variant Clone Form
// ---------------------------------------------------------------------------
interface VariantConfigSource {
model_provider: string;
model_name: string;
system_prompt: string;
user_prompt_template: string;
prompt_version: string;
temperature: number;
max_tokens: number;
context_window: number;
input_token_limit: number;
token_budget: number;
timeout_seconds: number;
max_retries: number;
}
function VariantCloneForm({ agentId, source, sourceType, sourceVariantId, onDone }: {
agentId: string;
source: VariantConfigSource;
sourceType: 'agent' | 'variant';
sourceVariantId?: string;
onDone: () => void;
}) {
const [form, setForm] = useState({
variant_name: '',
description: '',
model_provider: source.model_provider,
model_name: source.model_name,
system_prompt: source.system_prompt,
user_prompt_template: source.user_prompt_template,
prompt_version: source.prompt_version,
temperature: source.temperature,
max_tokens: source.max_tokens,
context_window: source.context_window,
input_token_limit: source.input_token_limit,
token_budget: source.token_budget,
timeout_seconds: source.timeout_seconds,
max_retries: source.max_retries,
});
const cloneAgentMut = useCloneAgentAsVariant(agentId);
const qc = useQueryClient();
// For cloning from a variant, we use a direct mutation since the hook needs variantId
const cloneVariantMut = useMutation({
mutationFn: (body: Record<string, unknown>) => apiPost<AgentVariant>('query', `/api/agents/${agentId}/variants/${sourceVariantId}/clone`, body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['agent-variants'] }); },
});
const mutation = sourceType === 'agent' ? cloneAgentMut : cloneVariantMut;
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
mutation.mutate(form, { onSuccess: () => onDone() });
}
return (
<Card>
<h2 className="mb-4 text-sm font-medium text-gray-400">
Clone {sourceType === 'agent' ? 'Agent' : 'Variant'} as New Variant
</h2>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<FormRow label="Variant Name">
<input value={form.variant_name} onChange={(e) => setForm({ ...form, variant_name: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" required />
</FormRow>
<FormRow label="Description">
<input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
</div>
<div className="grid grid-cols-2 gap-3">
<FormRow label="Provider">
<input value={form.model_provider} onChange={(e) => setForm({ ...form, model_provider: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
<FormRow label="Model">
<input value={form.model_name} onChange={(e) => setForm({ ...form, model_name: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" required />
</FormRow>
</div>
<FormRow label="System Prompt">
<textarea value={form.system_prompt} onChange={(e) => setForm({ ...form, system_prompt: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none h-24 font-mono text-xs" />
</FormRow>
<FormRow label="User Prompt Template">
<textarea value={form.user_prompt_template} onChange={(e) => setForm({ ...form, user_prompt_template: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none h-20 font-mono text-xs" />
</FormRow>
<div className="grid grid-cols-4 gap-3">
<FormRow label="Temperature">
<input type="number" step="0.1" min="0" max="2" value={form.temperature} onChange={(e) => setForm({ ...form, temperature: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
<FormRow label="Max Tokens">
<input type="number" value={form.max_tokens} onChange={(e) => setForm({ ...form, max_tokens: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
<FormRow label="Timeout (s)">
<input type="number" value={form.timeout_seconds} onChange={(e) => setForm({ ...form, timeout_seconds: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
<FormRow label="Max Retries">
<input type="number" value={form.max_retries} onChange={(e) => setForm({ ...form, max_retries: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
</div>
<div className="grid grid-cols-3 gap-3">
<FormRow label="Context Window (0 = default)">
<input type="number" min="0" value={form.context_window} onChange={(e) => setForm({ ...form, context_window: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
<FormRow label="Input Token Limit (0 = unlimited)">
<input type="number" min="0" value={form.input_token_limit} onChange={(e) => setForm({ ...form, input_token_limit: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
<FormRow label="Token Budget/hr (0 = unlimited)">
<input type="number" min="0" value={form.token_budget} onChange={(e) => setForm({ ...form, token_budget: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
</div>
<FormRow label="Prompt Version">
<input value={form.prompt_version} onChange={(e) => setForm({ ...form, prompt_version: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
<div className="flex gap-2 pt-2">
<button type="submit" disabled={mutation.isPending} className="rounded-md bg-brand-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
{mutation.isPending ? 'Cloning…' : 'Clone'}
</button>
<button type="button" onClick={onDone} className="rounded-md border border-surface-700 px-4 py-1.5 text-sm text-gray-400 hover:bg-surface-800">Cancel</button>
</div>
{mutation.isError && <p className="text-xs text-red-400">Failed to clone: {(mutation.error as ApiError)?.status === 409 ? 'Slug already exists' : 'Unknown error'}</p>}
</form>
</Card>
);
}
// ---------------------------------------------------------------------------
// Variant Edit Form
// ---------------------------------------------------------------------------
function VariantEditForm({ agentId, variant, onDone }: {
agentId: string;
variant: AgentVariant;
onDone: () => void;
}) {
const [form, setForm] = useState({
variant_name: variant.variant_name,
description: variant.description,
model_provider: variant.model_provider,
model_name: variant.model_name,
system_prompt: variant.system_prompt,
user_prompt_template: variant.user_prompt_template,
prompt_version: variant.prompt_version,
temperature: variant.temperature,
max_tokens: variant.max_tokens,
context_window: variant.context_window,
input_token_limit: variant.input_token_limit,
token_budget: variant.token_budget,
timeout_seconds: variant.timeout_seconds,
max_retries: variant.max_retries,
});
const mutation = useUpdateVariant(agentId, variant.id);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
mutation.mutate(form, { onSuccess: () => onDone() });
}
return (
<Card>
<h2 className="mb-4 text-sm font-medium text-gray-400">Edit Variant: {variant.variant_name}</h2>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<FormRow label="Variant Name">
<input value={form.variant_name} onChange={(e) => setForm({ ...form, variant_name: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" required />
</FormRow>
<FormRow label="Description">
<input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
</div>
<div className="grid grid-cols-2 gap-3">
<FormRow label="Provider">
<input value={form.model_provider} onChange={(e) => setForm({ ...form, model_provider: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
<FormRow label="Model">
<input value={form.model_name} onChange={(e) => setForm({ ...form, model_name: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" required />
</FormRow>
</div>
<FormRow label="System Prompt">
<textarea value={form.system_prompt} onChange={(e) => setForm({ ...form, system_prompt: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none h-24 font-mono text-xs" />
</FormRow>
<FormRow label="User Prompt Template">
<textarea value={form.user_prompt_template} onChange={(e) => setForm({ ...form, user_prompt_template: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none h-20 font-mono text-xs" />
</FormRow>
<div className="grid grid-cols-4 gap-3">
<FormRow label="Temperature">
<input type="number" step="0.1" min="0" max="2" value={form.temperature} onChange={(e) => setForm({ ...form, temperature: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
<FormRow label="Max Tokens">
<input type="number" value={form.max_tokens} onChange={(e) => setForm({ ...form, max_tokens: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
<FormRow label="Timeout (s)">
<input type="number" value={form.timeout_seconds} onChange={(e) => setForm({ ...form, timeout_seconds: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
<FormRow label="Max Retries">
<input type="number" value={form.max_retries} onChange={(e) => setForm({ ...form, max_retries: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
</div>
<div className="grid grid-cols-3 gap-3">
<FormRow label="Context Window (0 = default)">
<input type="number" min="0" value={form.context_window} onChange={(e) => setForm({ ...form, context_window: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
<FormRow label="Input Token Limit (0 = unlimited)">
<input type="number" min="0" value={form.input_token_limit} onChange={(e) => setForm({ ...form, input_token_limit: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
<FormRow label="Token Budget/hr (0 = unlimited)">
<input type="number" min="0" value={form.token_budget} onChange={(e) => setForm({ ...form, token_budget: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
</div>
<FormRow label="Prompt Version">
<input value={form.prompt_version} onChange={(e) => setForm({ ...form, prompt_version: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
</FormRow>
<div className="flex gap-2 pt-2">
<button type="submit" disabled={mutation.isPending} className="rounded-md bg-brand-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
{mutation.isPending ? 'Saving…' : 'Save'}
</button>
<button type="button" onClick={onDone} className="rounded-md border border-surface-700 px-4 py-1.5 text-sm text-gray-400 hover:bg-surface-800">Cancel</button>
</div>
{mutation.isError && <p className="text-xs text-red-400">Failed to save variant</p>}
</form>
</Card>
);
}
// ---------------------------------------------------------------------------
// Delete Variant Dialog
// ---------------------------------------------------------------------------
function DeleteVariantDialog({ agentId, variant, onDone }: {
agentId: string;
variant: AgentVariant;
onDone: () => void;
}) {
const mutation = useDeleteVariant(agentId, variant.id);
const [error, setError] = useState<string | null>(null);
function handleDelete() {
setError(null);
mutation.mutate(undefined, {
onSuccess: () => onDone(),
onError: (err) => {
if (err instanceof ApiError && err.status === 400) {
setError('Cannot delete the active variant. Deactivate it first or activate a different variant.');
} else {
setError('Failed to delete variant.');
}
},
});
}
return (
<Card>
<h2 className="mb-3 text-sm font-medium text-red-400">Delete Variant</h2>
<p className="text-sm text-gray-300 mb-1">
Are you sure you want to delete <span className="font-medium text-gray-100">{variant.variant_name}</span>?
</p>
<p className="text-xs text-gray-500 mb-4">This action cannot be undone. Associated performance log entries will be unlinked.</p>
{error && <p className="text-xs text-red-400 mb-3">{error}</p>}
<div className="flex gap-2">
<button onClick={handleDelete} disabled={mutation.isPending} className="rounded-md bg-red-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50">
{mutation.isPending ? 'Deleting…' : 'Delete'}
</button>
<button onClick={onDone} className="rounded-md border border-surface-700 px-4 py-1.5 text-sm text-gray-400 hover:bg-surface-800">Cancel</button>
</div>
</Card>
);
}
// ---------------------------------------------------------------------------
// Edit Form
// ---------------------------------------------------------------------------
+67
View File
@@ -54,6 +54,25 @@ export const mockCorporateDecisions = [
{ catalyst_type: 'm_and_a', date: '2026-03-15T00:00:00Z', summary: 'Acquisition of AI startup for $2B', trend_direction: 'bullish', trend_strength: 0.7, sample_count: 5, pattern_confidence: 0.68, document_id: 'd1' },
];
export const mockAgents = [
{ id: 'agent-1', name: 'Document Extractor', slug: 'document-extractor', purpose: 'Extract structured data from documents', model_provider: 'ollama', model_name: 'llama3.1:8b', system_prompt: 'You are a document extractor.', user_prompt_template: 'Extract from: {doc}', prompt_version: 'v1', schema_version: '1.0', temperature: 0.0, max_tokens: 32768, timeout_seconds: 120, max_retries: 2, active: true, source: 'system', created_at: '2026-04-01T00:00:00Z', updated_at: '2026-04-01T00:00:00Z' },
{ id: 'agent-2', name: 'Event Classifier', slug: 'event-classifier', purpose: 'Classify global events', model_provider: 'ollama', model_name: 'llama3.1:8b', system_prompt: 'You classify events.', user_prompt_template: 'Classify: {event}', prompt_version: 'v1', schema_version: '1.0', temperature: 0.1, max_tokens: 16384, timeout_seconds: 60, max_retries: 3, active: true, source: 'system', created_at: '2026-04-02T00:00:00Z', updated_at: '2026-04-02T00:00:00Z' },
];
export const mockAgentVariants = [
{ id: 'var-1', agent_id: 'agent-1', variant_name: 'GPT-4o Variant', variant_slug: 'gpt-4o-variant', description: 'Uses GPT-4o for extraction', model_provider: 'openai', model_name: 'gpt-4o', system_prompt: 'You are a document extractor.', user_prompt_template: 'Extract from: {doc}', prompt_version: 'v2', temperature: 0.2, max_tokens: 65536, context_window: 128000, input_token_limit: 100000, token_budget: 500000, timeout_seconds: 90, max_retries: 3, is_active: true, created_at: '2026-04-05T00:00:00Z', updated_at: '2026-04-05T00:00:00Z' },
{ id: 'var-2', agent_id: 'agent-1', variant_name: 'Mistral Variant', variant_slug: 'mistral-variant', description: 'Uses Mistral for extraction', model_provider: 'ollama', model_name: 'mistral:7b', system_prompt: 'You are a document extractor.', user_prompt_template: 'Extract from: {doc}', prompt_version: 'v1', temperature: 0.0, max_tokens: 32768, context_window: 0, input_token_limit: 0, token_budget: 0, timeout_seconds: 120, max_retries: 2, is_active: false, created_at: '2026-04-06T00:00:00Z', updated_at: '2026-04-06T00:00:00Z' },
];
export const mockVariantPerformance = {
total_invocations: 50, successes: 45, failures: 5, avg_duration_ms: 1200, p95_duration_ms: 2500, avg_confidence: 0.85, avg_retries: 0.3, total_input_tokens: 50000, total_output_tokens: 15000, success_rate: 0.9,
};
export const mockVariantPerfHistory = [
{ hour: '2026-04-10T10:00:00Z', invocations: 10, successes: 9, avg_duration_ms: 1100, avg_confidence: 0.88 },
{ hour: '2026-04-10T11:00:00Z', invocations: 12, successes: 11, avg_duration_ms: 1300, avg_confidence: 0.82 },
];
export const handlers = [
// Query API (proxied at /api/)
http.get('/api/companies', () => HttpResponse.json(mockCompanies)),
@@ -142,6 +161,54 @@ export const handlers = [
}),
http.get('/api/trends/:id/projection', () => HttpResponse.json(mockTrendProjection)),
// Agents
http.get('/api/agents', () => HttpResponse.json(mockAgents)),
http.get('/api/agents/:agent_id', ({ params }) => {
const a = mockAgents.find((a) => a.id === params.agent_id);
return a ? HttpResponse.json(a) : new HttpResponse(null, { status: 404 });
}),
http.get('/api/agents/:agent_id/performance', () => HttpResponse.json(mockVariantPerformance)),
http.get('/api/agents/:agent_id/performance/history', () => HttpResponse.json(mockVariantPerfHistory)),
// Agent Variants
http.get('/api/agents/:agent_id/variants', ({ params }) =>
HttpResponse.json(mockAgentVariants.filter((v) => v.agent_id === params.agent_id)),
),
http.get('/api/agents/:agent_id/variants/:variant_id', ({ params }) => {
const v = mockAgentVariants.find((v) => v.id === params.variant_id && v.agent_id === params.agent_id);
return v ? HttpResponse.json(v) : new HttpResponse(null, { status: 404 });
}),
http.post('/api/agents/:agent_id/variants', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ id: 'var-new', agent_id: params.agent_id, variant_name: body.variant_name, variant_slug: body.variant_slug ?? 'new-variant', is_active: false, ...body, created_at: '2026-04-10T00:00:00Z', updated_at: '2026-04-10T00:00:00Z' }, { status: 201 });
}),
http.post('/api/agents/:agent_id/clone', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ id: 'var-cloned', agent_id: params.agent_id, variant_name: body.variant_name, variant_slug: 'cloned-variant', is_active: false, ...body, created_at: '2026-04-10T00:00:00Z', updated_at: '2026-04-10T00:00:00Z' }, { status: 201 });
}),
http.post('/api/agents/:agent_id/variants/:variant_id/clone', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ id: 'var-clone2', agent_id: params.agent_id, variant_name: body.variant_name, variant_slug: 'clone2', is_active: false, ...body, created_at: '2026-04-10T00:00:00Z', updated_at: '2026-04-10T00:00:00Z' }, { status: 201 });
}),
http.put('/api/agents/:agent_id/variants/:variant_id', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const v = mockAgentVariants.find((v) => v.id === params.variant_id);
return v ? HttpResponse.json({ ...v, ...body, updated_at: '2026-04-10T12:00:00Z' }) : new HttpResponse(null, { status: 404 });
}),
http.delete('/api/agents/:agent_id/variants/:variant_id', ({ params }) => {
const v = mockAgentVariants.find((v) => v.id === params.variant_id);
if (!v) return new HttpResponse(null, { status: 404 });
if (v.is_active) return HttpResponse.json({ detail: 'Cannot delete active variant' }, { status: 400 });
return HttpResponse.json({ status: 'deleted' });
}),
http.post('/api/agents/:agent_id/variants/:variant_id/activate', ({ params }) => {
const v = mockAgentVariants.find((v) => v.id === params.variant_id);
return v ? HttpResponse.json({ ...v, is_active: true }) : new HttpResponse(null, { status: 404 });
}),
http.post('/api/agents/:agent_id/variants/deactivate', () => HttpResponse.json({ deactivated: true })),
http.get('/api/agents/:agent_id/variants/:variant_id/performance', () => HttpResponse.json(mockVariantPerformance)),
http.get('/api/agents/:agent_id/variants/:variant_id/performance/history', () => HttpResponse.json(mockVariantPerfHistory)),
// Competitive intelligence endpoints
http.get('/registry/companies/:id/competitors', () => HttpResponse.json(mockCompetitors)),
http.post('/registry/companies/:id/competitors/infer', () => HttpResponse.json(mockCompetitors)),
+40
View File
@@ -168,3 +168,43 @@ describe('Global Events page', () => {
});
});
});
describe('Agents page', () => {
it('renders agent list in sidebar', async () => {
renderRoute('/agents');
await waitFor(() => {
expect(screen.getByText('Document Extractor')).toBeInTheDocument();
expect(screen.getByText('Event Classifier')).toBeInTheDocument();
});
});
it('renders variant list when an agent is selected', async () => {
renderRoute('/agents');
await waitFor(() => expect(screen.getByText('Document Extractor')).toBeInTheDocument());
await userEvent.click(screen.getByText('Document Extractor'));
await waitFor(() => {
expect(screen.getByText('GPT-4o Variant')).toBeInTheDocument();
expect(screen.getByText('Mistral Variant')).toBeInTheDocument();
});
});
it('shows comparison view when multiple variants are checked', async () => {
renderRoute('/agents');
await waitFor(() => expect(screen.getByText('Document Extractor')).toBeInTheDocument());
await userEvent.click(screen.getByText('Document Extractor'));
await waitFor(() => expect(screen.getByText('GPT-4o Variant')).toBeInTheDocument());
// Select both variant checkboxes
const checkboxes = screen.getAllByRole('checkbox');
await userEvent.click(checkboxes[0]);
await userEvent.click(checkboxes[1]);
await waitFor(() => {
expect(screen.getByText(/Variant Comparison/)).toBeInTheDocument();
});
});
});