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:
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user