feat(FN-1639): merge fusion/fn-1639
This commit is contained in:
804
packages/dashboard/app/components/AgentPromptsManager.tsx
Normal file
804
packages/dashboard/app/components/AgentPromptsManager.tsx
Normal file
@@ -0,0 +1,804 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { BUILTIN_AGENT_PROMPTS, PROMPT_KEY_CATALOG } from "../utils/builtinPrompts";
|
||||
import type { AgentPromptTemplate, AgentPromptsConfig, AgentCapability } from "@fusion/core";
|
||||
import type { PromptKey } from "@fusion/core";
|
||||
import { X, Plus, Pencil, Trash2, BookOpen, Users, Settings2, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Props for the AgentPromptsManager component.
|
||||
*
|
||||
* Provides a unified interface for managing:
|
||||
* - Template customization (built-in templates as read-only, custom templates as editable)
|
||||
* - Role assignments (mapping roles to template IDs)
|
||||
* - Prompt overrides (segment-level customization)
|
||||
*/
|
||||
interface AgentPromptsManagerProps {
|
||||
/** Current agent prompts configuration from settings */
|
||||
value: AgentPromptsConfig | undefined;
|
||||
/** Callback when agent prompts configuration changes */
|
||||
onChange: (value: AgentPromptsConfig) => void;
|
||||
/** Current prompt overrides from settings */
|
||||
promptOverrides: Record<PromptKey, string | null> | undefined;
|
||||
/** Callback when prompt overrides change */
|
||||
onPromptOverridesChange: (value: Record<PromptKey, string | null>) => void;
|
||||
}
|
||||
|
||||
/** Tab identifiers */
|
||||
type TabId = "templates" | "assignments" | "overrides";
|
||||
|
||||
/** Core agent roles that have built-in templates */
|
||||
const CORE_ROLES: AgentCapability[] = ["executor", "triage", "reviewer", "merger"];
|
||||
|
||||
/** Role display labels */
|
||||
const ROLE_LABELS: Record<AgentCapability, string> = {
|
||||
executor: "Executor Agent",
|
||||
triage: "Triage Agent",
|
||||
reviewer: "Reviewer Agent",
|
||||
merger: "Merger Agent",
|
||||
scheduler: "Scheduler Agent",
|
||||
engineer: "Engineer Agent",
|
||||
custom: "Custom Agent",
|
||||
};
|
||||
|
||||
/** Role badge colors */
|
||||
const ROLE_COLORS: Record<AgentCapability, string> = {
|
||||
executor: "#3b82f6",
|
||||
triage: "#f59e0b",
|
||||
reviewer: "#8b5cf6",
|
||||
merger: "#10b981",
|
||||
scheduler: "#06b6d4",
|
||||
engineer: "#ec4899",
|
||||
custom: "#6b7280",
|
||||
};
|
||||
|
||||
/** Form data for editing/creating a custom template */
|
||||
interface TemplateFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
role: AgentCapability;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
const EMPTY_TEMPLATE_FORM: TemplateFormData = {
|
||||
name: "",
|
||||
description: "",
|
||||
role: "executor",
|
||||
prompt: "",
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a kebab-case ID from a template name.
|
||||
* If collision exists with built-in or existing custom IDs, append -2, -3, etc.
|
||||
*/
|
||||
function generateTemplateId(
|
||||
name: string,
|
||||
existingCustomTemplates: AgentPromptTemplate[],
|
||||
): string {
|
||||
// Generate base kebab-case ID
|
||||
const baseId = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.slice(0, 50);
|
||||
|
||||
// Get all IDs that would cause a collision
|
||||
const builtinIds = new Set(BUILTIN_AGENT_PROMPTS.map((t) => t.id));
|
||||
const customIds = new Set(existingCustomTemplates.map((t) => t.id));
|
||||
|
||||
// If no collision, return base ID
|
||||
if (!builtinIds.has(baseId) && !customIds.has(baseId)) {
|
||||
return baseId;
|
||||
}
|
||||
|
||||
// Find the next available number
|
||||
let counter = 2;
|
||||
while (builtinIds.has(`${baseId}-${counter}`) || customIds.has(`${baseId}-${counter}`)) {
|
||||
counter++;
|
||||
}
|
||||
|
||||
return `${baseId}-${counter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text to a specified length with ellipsis.
|
||||
*/
|
||||
function truncate(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
/**
|
||||
* AgentPromptsManager - A unified component for managing agent prompt templates,
|
||||
* role assignments, and prompt segment overrides.
|
||||
*
|
||||
* Provides three tabs:
|
||||
* 1. **Templates**: View built-in templates, create/edit/delete custom templates
|
||||
* 2. **Assignments**: Map agent roles to specific templates
|
||||
* 3. **Overrides**: Customize specific segments of agent prompts
|
||||
*/
|
||||
export function AgentPromptsManager({
|
||||
value,
|
||||
onChange,
|
||||
promptOverrides,
|
||||
onPromptOverridesChange,
|
||||
}: AgentPromptsManagerProps) {
|
||||
// Tab state
|
||||
const [activeTab, setActiveTab] = useState<TabId>("templates");
|
||||
|
||||
// Template editing state
|
||||
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [templateForm, setTemplateForm] = useState<TemplateFormData>(EMPTY_TEMPLATE_FORM);
|
||||
const [templateIdError, setTemplateIdError] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
// Override expanded state for accordion behavior
|
||||
const [expandedOverrides, setExpandedOverrides] = useState<Set<PromptKey>>(new Set());
|
||||
|
||||
// Get custom templates from current config
|
||||
const customTemplates = value?.templates ?? [];
|
||||
|
||||
// Get role assignments from current config
|
||||
const roleAssignments = value?.roleAssignments ?? {};
|
||||
|
||||
// Get templates for a specific role (both built-in and custom)
|
||||
const getTemplatesForRole = useCallback(
|
||||
(role: AgentCapability): AgentPromptTemplate[] => {
|
||||
const builtIn = BUILTIN_AGENT_PROMPTS.filter((t) => t.role === role);
|
||||
const custom = customTemplates.filter((t) => t.role === role);
|
||||
return [...builtIn, ...custom];
|
||||
},
|
||||
[customTemplates],
|
||||
);
|
||||
|
||||
// Handle starting template creation
|
||||
const handleStartCreate = useCallback(() => {
|
||||
setIsCreating(true);
|
||||
setEditingTemplateId(null);
|
||||
setTemplateForm(EMPTY_TEMPLATE_FORM);
|
||||
setTemplateIdError(null);
|
||||
}, []);
|
||||
|
||||
// Handle starting template edit
|
||||
const handleStartEdit = useCallback((template: AgentPromptTemplate) => {
|
||||
setEditingTemplateId(template.id);
|
||||
setIsCreating(false);
|
||||
setTemplateForm({
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
role: template.role,
|
||||
prompt: template.prompt,
|
||||
});
|
||||
setTemplateIdError(null);
|
||||
}, []);
|
||||
|
||||
// Handle canceling template edit
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setEditingTemplateId(null);
|
||||
setIsCreating(false);
|
||||
setTemplateForm(EMPTY_TEMPLATE_FORM);
|
||||
setTemplateIdError(null);
|
||||
}, []);
|
||||
|
||||
// Handle saving a template (create or update)
|
||||
const handleSaveTemplate = useCallback(() => {
|
||||
const trimmedName = templateForm.name.trim();
|
||||
if (!trimmedName) {
|
||||
setTemplateIdError("Template name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate ID for new templates
|
||||
let templateId: string;
|
||||
if (isCreating) {
|
||||
templateId = generateTemplateId(trimmedName, customTemplates);
|
||||
|
||||
// Check for collision with built-in IDs (shouldn't happen with generateTemplateId, but be defensive)
|
||||
const builtinIds = new Set(BUILTIN_AGENT_PROMPTS.map((t) => t.id));
|
||||
if (builtinIds.has(templateId)) {
|
||||
setTemplateIdError(`Template ID "${templateId}" conflicts with a built-in template. Please use a different name.`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
templateId = editingTemplateId!;
|
||||
}
|
||||
|
||||
const newTemplate: AgentPromptTemplate = {
|
||||
id: templateId,
|
||||
name: trimmedName,
|
||||
description: templateForm.description.trim(),
|
||||
role: templateForm.role,
|
||||
prompt: templateForm.prompt,
|
||||
builtIn: false,
|
||||
};
|
||||
|
||||
let newTemplates: AgentPromptTemplate[];
|
||||
if (isCreating) {
|
||||
newTemplates = [...customTemplates, newTemplate];
|
||||
} else {
|
||||
newTemplates = customTemplates.map((t) =>
|
||||
t.id === templateId ? newTemplate : t,
|
||||
);
|
||||
}
|
||||
|
||||
// If changing role, clear any assignment to this template
|
||||
const newAssignments = { ...roleAssignments };
|
||||
for (const [role, assignedId] of Object.entries(newAssignments)) {
|
||||
if (assignedId === templateId && templateForm.role !== role) {
|
||||
delete newAssignments[role as AgentCapability];
|
||||
}
|
||||
}
|
||||
|
||||
onChange({
|
||||
...value,
|
||||
templates: newTemplates,
|
||||
roleAssignments: Object.keys(newAssignments).length > 0 ? newAssignments : undefined,
|
||||
});
|
||||
|
||||
handleCancelEdit();
|
||||
}, [
|
||||
templateForm,
|
||||
isCreating,
|
||||
editingTemplateId,
|
||||
customTemplates,
|
||||
roleAssignments,
|
||||
value,
|
||||
onChange,
|
||||
handleCancelEdit,
|
||||
]);
|
||||
|
||||
// Handle deleting a template
|
||||
const handleDeleteTemplate = useCallback(
|
||||
(templateId: string) => {
|
||||
// Remove the template
|
||||
const newTemplates = customTemplates.filter((t) => t.id !== templateId);
|
||||
|
||||
// Clear any role assignments pointing to this template
|
||||
const newAssignments = { ...roleAssignments };
|
||||
let assignmentCleared = false;
|
||||
for (const [role, assignedId] of Object.entries(newAssignments)) {
|
||||
if (assignedId === templateId) {
|
||||
delete newAssignments[role as AgentCapability];
|
||||
assignmentCleared = true;
|
||||
}
|
||||
}
|
||||
|
||||
onChange({
|
||||
...value,
|
||||
templates: newTemplates.length > 0 ? newTemplates : undefined,
|
||||
roleAssignments:
|
||||
Object.keys(newAssignments).length > 0 || assignmentCleared
|
||||
? Object.keys(newAssignments).length > 0
|
||||
? newAssignments
|
||||
: undefined
|
||||
: roleAssignments,
|
||||
});
|
||||
|
||||
setDeleteConfirmId(null);
|
||||
if (editingTemplateId === templateId) {
|
||||
handleCancelEdit();
|
||||
}
|
||||
},
|
||||
[customTemplates, roleAssignments, value, onChange, editingTemplateId, handleCancelEdit],
|
||||
);
|
||||
|
||||
// Handle changing a role assignment
|
||||
const handleRoleAssignmentChange = useCallback(
|
||||
(role: AgentCapability, templateId: string) => {
|
||||
const newAssignments = { ...roleAssignments };
|
||||
if (templateId === "") {
|
||||
delete newAssignments[role];
|
||||
} else {
|
||||
newAssignments[role] = templateId;
|
||||
}
|
||||
|
||||
onChange({
|
||||
...value,
|
||||
roleAssignments: Object.keys(newAssignments).length > 0 ? newAssignments : undefined,
|
||||
});
|
||||
},
|
||||
[roleAssignments, value, onChange],
|
||||
);
|
||||
|
||||
// Handle prompt override change
|
||||
const handlePromptOverrideChange = useCallback(
|
||||
(key: PromptKey, value: string) => {
|
||||
onPromptOverridesChange({
|
||||
...promptOverrides,
|
||||
[key]: value || null,
|
||||
});
|
||||
},
|
||||
[promptOverrides, onPromptOverridesChange],
|
||||
);
|
||||
|
||||
// Handle reset (set to null) for a prompt override
|
||||
const handleResetOverride = useCallback(
|
||||
(key: PromptKey) => {
|
||||
onPromptOverridesChange({
|
||||
...promptOverrides,
|
||||
[key]: null,
|
||||
});
|
||||
},
|
||||
[promptOverrides, onPromptOverridesChange],
|
||||
);
|
||||
|
||||
// Toggle override accordion
|
||||
const toggleOverrideExpanded = useCallback((key: PromptKey) => {
|
||||
setExpandedOverrides((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Check if a template ID collides with built-in
|
||||
const isBuiltinId = (id: string): boolean => {
|
||||
return BUILTIN_AGENT_PROMPTS.some((t) => t.id === id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="prompt-manager">
|
||||
{/* Tab Navigation */}
|
||||
<div className="prompt-manager-tabs">
|
||||
<button
|
||||
className={`prompt-manager-tab ${activeTab === "templates" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("templates")}
|
||||
data-testid="tab-templates"
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
Templates
|
||||
</button>
|
||||
<button
|
||||
className={`prompt-manager-tab ${activeTab === "assignments" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("assignments")}
|
||||
data-testid="tab-assignments"
|
||||
>
|
||||
<Users size={14} />
|
||||
Assignments
|
||||
</button>
|
||||
<button
|
||||
className={`prompt-manager-tab ${activeTab === "overrides" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("overrides")}
|
||||
data-testid="tab-overrides"
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
Overrides
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="prompt-manager-content">
|
||||
{/* Templates Tab */}
|
||||
{activeTab === "templates" && (
|
||||
<div className="prompt-manager-templates-tab" data-testid="templates-tab">
|
||||
{/* Template Editor (shown when creating or editing) */}
|
||||
{(isCreating || editingTemplateId !== null) && (
|
||||
<div className="prompt-template-editor" data-testid="template-editor">
|
||||
<h4 className="prompt-template-editor-title">
|
||||
{isCreating ? "New Custom Template" : "Edit Custom Template"}
|
||||
</h4>
|
||||
|
||||
<div className="prompt-template-editor-fields">
|
||||
<div className="prompt-template-field">
|
||||
<label htmlFor="template-name">Name</label>
|
||||
<input
|
||||
id="template-name"
|
||||
type="text"
|
||||
value={templateForm.name}
|
||||
onChange={(e) =>
|
||||
setTemplateForm((f) => ({ ...f, name: e.target.value }))
|
||||
}
|
||||
placeholder="e.g. My Custom Executor"
|
||||
data-testid="template-name-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="prompt-template-field">
|
||||
<label htmlFor="template-description">Description</label>
|
||||
<input
|
||||
id="template-description"
|
||||
type="text"
|
||||
value={templateForm.description}
|
||||
onChange={(e) =>
|
||||
setTemplateForm((f) => ({ ...f, description: e.target.value }))
|
||||
}
|
||||
placeholder="Brief description of this template"
|
||||
data-testid="template-description-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="prompt-template-field">
|
||||
<label htmlFor="template-role">Role</label>
|
||||
<select
|
||||
id="template-role"
|
||||
value={templateForm.role}
|
||||
onChange={(e) =>
|
||||
setTemplateForm((f) => ({
|
||||
...f,
|
||||
role: e.target.value as AgentCapability,
|
||||
}))
|
||||
}
|
||||
data-testid="template-role-select"
|
||||
>
|
||||
{CORE_ROLES.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{ROLE_LABELS[role]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="prompt-template-field">
|
||||
<label htmlFor="template-prompt">Prompt</label>
|
||||
<textarea
|
||||
id="template-prompt"
|
||||
value={templateForm.prompt}
|
||||
onChange={(e) =>
|
||||
setTemplateForm((f) => ({ ...f, prompt: e.target.value }))
|
||||
}
|
||||
placeholder="Enter the system prompt for this template..."
|
||||
rows={8}
|
||||
className="prompt-template-prompt-textarea"
|
||||
data-testid="template-prompt-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{templateIdError && (
|
||||
<div className="prompt-template-error" data-testid="template-error">
|
||||
{templateIdError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="prompt-template-editor-actions">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleCancelEdit}
|
||||
data-testid="cancel-template-btn"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSaveTemplate}
|
||||
data-testid="save-template-btn"
|
||||
>
|
||||
{isCreating ? "Create" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Built-in Templates Section */}
|
||||
<div className="prompt-template-section" data-testid="builtin-templates">
|
||||
<h4 className="prompt-template-section-title">Built-in Templates</h4>
|
||||
<p className="prompt-template-section-desc">
|
||||
These templates are provided by Fusion and cannot be modified.
|
||||
</p>
|
||||
<div className="prompt-template-list">
|
||||
{BUILTIN_AGENT_PROMPTS.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="prompt-template-card"
|
||||
data-testid={`builtin-template-${template.id}`}
|
||||
>
|
||||
<div className="prompt-template-card-header">
|
||||
<div className="prompt-template-card-info">
|
||||
<span className="prompt-template-card-name">
|
||||
{template.name}
|
||||
</span>
|
||||
<span
|
||||
className="prompt-template-badge-built-in"
|
||||
style={{ borderColor: ROLE_COLORS[template.role] }}
|
||||
>
|
||||
Built-in
|
||||
</span>
|
||||
<span
|
||||
className="prompt-template-badge-role"
|
||||
style={{ backgroundColor: ROLE_COLORS[template.role] + "20", color: ROLE_COLORS[template.role] }}
|
||||
>
|
||||
{ROLE_LABELS[template.role]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="prompt-template-card-description">
|
||||
{template.description}
|
||||
</p>
|
||||
<div className="prompt-template-card-preview">
|
||||
<code>{truncate(template.prompt, 200)}</code>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Templates Section */}
|
||||
<div className="prompt-template-section" data-testid="custom-templates">
|
||||
<h4 className="prompt-template-section-title">Custom Templates</h4>
|
||||
<p className="prompt-template-section-desc">
|
||||
Create custom templates to override built-in prompts for specific roles.
|
||||
</p>
|
||||
|
||||
{customTemplates.length === 0 && !isCreating && (
|
||||
<div className="prompt-template-empty">
|
||||
No custom templates yet. Create one to get started.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{customTemplates.length > 0 && (
|
||||
<div className="prompt-template-list">
|
||||
{customTemplates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="prompt-template-card"
|
||||
data-testid={`custom-template-${template.id}`}
|
||||
>
|
||||
{deleteConfirmId === template.id ? (
|
||||
<div className="prompt-template-delete-confirm">
|
||||
<p>Delete "{template.name}"?</p>
|
||||
<div className="prompt-template-delete-actions">
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleDeleteTemplate(template.id)}
|
||||
data-testid={`confirm-delete-${template.id}`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
data-testid={`cancel-delete-${template.id}`}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="prompt-template-card-header">
|
||||
<div className="prompt-template-card-info">
|
||||
<span className="prompt-template-card-name">
|
||||
{template.name}
|
||||
</span>
|
||||
<span className="prompt-template-badge-custom">
|
||||
Custom
|
||||
</span>
|
||||
<span
|
||||
className="prompt-template-badge-role"
|
||||
style={{
|
||||
backgroundColor: ROLE_COLORS[template.role] + "20",
|
||||
color: ROLE_COLORS[template.role],
|
||||
}}
|
||||
>
|
||||
{ROLE_LABELS[template.role]}
|
||||
</span>
|
||||
{/* Show override indicator if this custom template overrides a built-in */}
|
||||
{isBuiltinId(template.id) && (
|
||||
<span className="prompt-template-badge-override">
|
||||
Overrides built-in
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="prompt-template-card-actions">
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => handleStartEdit(template)}
|
||||
title="Edit"
|
||||
aria-label={`Edit ${template.name}`}
|
||||
data-testid={`edit-${template.id}`}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setDeleteConfirmId(template.id)}
|
||||
title="Delete"
|
||||
aria-label={`Delete ${template.name}`}
|
||||
data-testid={`delete-${template.id}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="prompt-template-card-description">
|
||||
{template.description}
|
||||
</p>
|
||||
<div className="prompt-template-card-preview">
|
||||
<code>{truncate(template.prompt, 200)}</code>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Custom Template Button */}
|
||||
{!isCreating && editingTemplateId === null && (
|
||||
<button
|
||||
className="btn btn-primary prompt-template-add-btn"
|
||||
onClick={handleStartCreate}
|
||||
data-testid="add-template-btn"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Custom Template
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignments Tab */}
|
||||
{activeTab === "assignments" && (
|
||||
<div className="prompt-manager-assignments-tab" data-testid="assignments-tab">
|
||||
<p className="prompt-assignments-desc">
|
||||
Assign specific templates to agent roles. When a role has an assignment, that
|
||||
template will be used instead of the default built-in.
|
||||
</p>
|
||||
|
||||
<div className="prompt-role-assignment-list">
|
||||
{CORE_ROLES.map((role) => {
|
||||
const availableTemplates = getTemplatesForRole(role);
|
||||
const currentAssignment = roleAssignments[role] ?? "";
|
||||
const selectedTemplate = availableTemplates.find(
|
||||
(t) => t.id === currentAssignment,
|
||||
);
|
||||
const isOverriding = !!currentAssignment;
|
||||
const isOverridingBuiltin = isBuiltinId(currentAssignment);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={role}
|
||||
className="prompt-role-assignment-row"
|
||||
data-testid={`assignment-${role}`}
|
||||
>
|
||||
<div className="prompt-role-assignment-label">
|
||||
<span
|
||||
className="prompt-role-badge"
|
||||
style={{
|
||||
backgroundColor: ROLE_COLORS[role] + "20",
|
||||
color: ROLE_COLORS[role],
|
||||
}}
|
||||
>
|
||||
{ROLE_LABELS[role]}
|
||||
</span>
|
||||
{isOverriding && (
|
||||
<span className="prompt-role-assignment-status">
|
||||
{selectedTemplate?.name ?? "Custom"} (overrides default)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
className="prompt-role-select"
|
||||
value={currentAssignment}
|
||||
onChange={(e) =>
|
||||
handleRoleAssignmentChange(role, e.target.value)
|
||||
}
|
||||
data-testid={`select-${role}`}
|
||||
>
|
||||
<option value="">Use default</option>
|
||||
{availableTemplates.map((template) => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.name}
|
||||
{isBuiltinId(template.id) ? " (built-in)" : " (custom)"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{Object.keys(roleAssignments).length > 0 && (
|
||||
<div className="prompt-assignments-note">
|
||||
<strong>Note:</strong> Role assignments are stored in the agentPrompts
|
||||
configuration. Custom templates override built-ins by ID.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overrides Tab */}
|
||||
{activeTab === "overrides" && (
|
||||
<div className="prompt-manager-overrides-tab" data-testid="overrides-tab">
|
||||
<p className="prompt-overrides-desc">
|
||||
Customize specific segments of AI agent prompts. Edits override built-in
|
||||
defaults. Use the Reset button to restore the original default for any
|
||||
prompt.
|
||||
</p>
|
||||
|
||||
<div className="prompt-overrides-list">
|
||||
{Object.values(PROMPT_KEY_CATALOG).map((promptMeta) => {
|
||||
const key = promptMeta.key;
|
||||
const currentOverride = promptOverrides?.[key] ?? "";
|
||||
const hasOverride = currentOverride !== "";
|
||||
const isExpanded = expandedOverrides.has(key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="prompt-override-item"
|
||||
data-testid={`override-${key}`}
|
||||
>
|
||||
<div
|
||||
className="prompt-override-header"
|
||||
onClick={() => toggleOverrideExpanded(key)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
toggleOverrideExpanded(key);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="prompt-override-info">
|
||||
<span className="prompt-override-name">
|
||||
{promptMeta.name}
|
||||
</span>
|
||||
<code className="prompt-override-key">{key}</code>
|
||||
{hasOverride && (
|
||||
<span className="prompt-override-badge">customized</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="prompt-override-expand-btn"
|
||||
aria-label={isExpanded ? "Collapse" : "Expand"}
|
||||
data-testid={`expand-${key}`}
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="prompt-override-description">
|
||||
{promptMeta.description}
|
||||
</p>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="prompt-override-editor">
|
||||
<textarea
|
||||
id={`prompt-${key}`}
|
||||
aria-label={`${promptMeta.name} prompt override (${key})`}
|
||||
className="prompt-override-textarea"
|
||||
value={currentOverride}
|
||||
onChange={(e) => {
|
||||
handlePromptOverrideChange(key, e.target.value);
|
||||
}}
|
||||
placeholder={`Default: ${promptMeta.defaultContent.slice(0, 100)}${promptMeta.defaultContent.length > 100 ? "..." : ""}`}
|
||||
rows={4}
|
||||
data-testid={`override-input-${key}`}
|
||||
/>
|
||||
<div className="prompt-override-footer">
|
||||
{hasOverride && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleResetOverride(key);
|
||||
}}
|
||||
data-testid={`reset-${key}`}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
<small className="prompt-override-hint">
|
||||
{hasOverride
|
||||
? "Custom override active. Click Reset to restore default."
|
||||
: `No override set. Using built-in default (${promptMeta.defaultContent.length} chars).`}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Globe, Folder } from "lucide-react";
|
||||
import { THINKING_LEVELS, PROMPT_KEY_CATALOG, isGlobalSettingsKey, isProjectSettingsKey } from "@fusion/core";
|
||||
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent } from "@fusion/core";
|
||||
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, PromptKey, AgentPromptsConfig } from "@fusion/core";
|
||||
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemory, saveMemory, fetchGlobalConcurrency, updateGlobalConcurrency } from "../api";
|
||||
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryBackendCapabilities } from "../api";
|
||||
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
|
||||
@@ -10,6 +10,7 @@ import { ThemeSelector } from "./ThemeSelector";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { FileEditor } from "./FileEditor";
|
||||
import { PluginManager } from "./PluginManager";
|
||||
import { AgentPromptsManager } from "./AgentPromptsManager";
|
||||
import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets";
|
||||
|
||||
/**
|
||||
@@ -2094,73 +2095,22 @@ export function SettingsModal({
|
||||
<>
|
||||
{renderScopeBanner()}
|
||||
<h4 className="settings-section-heading">Prompts</h4>
|
||||
<p className="settings-note">
|
||||
Customize specific segments of AI agent prompts. Edits override built-in defaults.
|
||||
Use the Reset button to restore the original default for any prompt.
|
||||
</p>
|
||||
<div className="prompt-overrides-list">
|
||||
{Object.values(PROMPT_KEY_CATALOG).map((promptMeta) => {
|
||||
const key = promptMeta.key;
|
||||
const currentOverride = form.promptOverrides?.[key] ?? "";
|
||||
const hasOverride = currentOverride !== "";
|
||||
return (
|
||||
<div key={key} className="prompt-override-item">
|
||||
<div className="prompt-override-header">
|
||||
<div className="prompt-override-info">
|
||||
<span className="prompt-override-name">{promptMeta.name}</span>
|
||||
<code className="prompt-override-key">{key}</code>
|
||||
{hasOverride && (
|
||||
<span className="prompt-override-badge">customized</span>
|
||||
)}
|
||||
</div>
|
||||
{hasOverride && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
promptOverrides: {
|
||||
...f.promptOverrides,
|
||||
[key]: null as unknown as string,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="prompt-override-description">{promptMeta.description}</p>
|
||||
<div className="prompt-override-editor">
|
||||
<textarea
|
||||
id={`prompt-${key}`}
|
||||
aria-label={`${promptMeta.name} prompt override (${key})`}
|
||||
className="prompt-override-textarea"
|
||||
value={currentOverride}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
promptOverrides: {
|
||||
...f.promptOverrides,
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
placeholder={`Default: ${promptMeta.defaultContent.slice(0, 100)}${promptMeta.defaultContent.length > 100 ? "..." : ""}`}
|
||||
rows={4}
|
||||
/>
|
||||
<small className="prompt-override-hint">
|
||||
{hasOverride
|
||||
? "Custom override active. Click Reset to restore default."
|
||||
: `No override set. Using built-in default (${promptMeta.defaultContent.length} chars).`}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<AgentPromptsManager
|
||||
value={form.agentPrompts}
|
||||
onChange={(agentPrompts: AgentPromptsConfig) => {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
agentPrompts,
|
||||
}));
|
||||
}}
|
||||
promptOverrides={form.promptOverrides}
|
||||
onPromptOverridesChange={(promptOverrides: Record<PromptKey, string | null> | undefined) => {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
promptOverrides,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
case "plugins":
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AgentPromptsManager } from "../AgentPromptsManager";
|
||||
import type { AgentPromptsConfig, AgentPromptTemplate } from "@fusion/core";
|
||||
|
||||
// Mock the builtinPrompts utility to avoid importing the large prompt texts
|
||||
vi.mock("../../utils/builtinPrompts", () => ({
|
||||
BUILTIN_AGENT_PROMPTS: [
|
||||
{
|
||||
id: "default-executor",
|
||||
name: "Default Executor",
|
||||
description: "Standard task execution agent with full tooling.",
|
||||
role: "executor",
|
||||
prompt: "You are a task execution agent...",
|
||||
builtIn: true,
|
||||
},
|
||||
{
|
||||
id: "default-triage",
|
||||
name: "Default Triage",
|
||||
description: "Standard task specification agent.",
|
||||
role: "triage",
|
||||
prompt: "You are a task specification agent...",
|
||||
builtIn: true,
|
||||
},
|
||||
{
|
||||
id: "default-reviewer",
|
||||
name: "Default Reviewer",
|
||||
description: "Standard independent code and plan reviewer.",
|
||||
role: "reviewer",
|
||||
prompt: "You are an independent code and plan reviewer.",
|
||||
builtIn: true,
|
||||
},
|
||||
{
|
||||
id: "default-merger",
|
||||
name: "Default Merger",
|
||||
description: "Standard merge agent for squash merges.",
|
||||
role: "merger",
|
||||
prompt: "You are a merge agent.",
|
||||
builtIn: true,
|
||||
},
|
||||
],
|
||||
PROMPT_KEY_CATALOG: {
|
||||
"executor-welcome": {
|
||||
key: "executor-welcome",
|
||||
name: "Executor Welcome",
|
||||
roles: ["executor"],
|
||||
description: "Introductory section for the executor",
|
||||
defaultContent: "You are a task execution agent...",
|
||||
},
|
||||
"triage-welcome": {
|
||||
key: "triage-welcome",
|
||||
name: "Triage Welcome",
|
||||
roles: ["triage"],
|
||||
description: "Introductory section for triage",
|
||||
defaultContent: "You are a task specification agent...",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const defaultConfig: AgentPromptsConfig = {};
|
||||
|
||||
const onChange = vi.fn();
|
||||
const onPromptOverridesChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("AgentPromptsManager", () => {
|
||||
describe("Tab Navigation", () => {
|
||||
it("renders all three tabs", () => {
|
||||
render(
|
||||
<AgentPromptsManager
|
||||
value={defaultConfig}
|
||||
onChange={onChange}
|
||||
promptOverrides={{}}
|
||||
onPromptOverridesChange={onPromptOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("tab-templates")).toBeTruthy();
|
||||
expect(screen.getByTestId("tab-assignments")).toBeTruthy();
|
||||
expect(screen.getByTestId("tab-overrides")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Templates tab is active by default", () => {
|
||||
render(
|
||||
<AgentPromptsManager
|
||||
value={defaultConfig}
|
||||
onChange={onChange}
|
||||
promptOverrides={{}}
|
||||
onPromptOverridesChange={onPromptOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("tab-templates")).toHaveClass(/active/);
|
||||
});
|
||||
|
||||
it("clicking a tab switches active state", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentPromptsManager
|
||||
value={defaultConfig}
|
||||
onChange={onChange}
|
||||
promptOverrides={{}}
|
||||
onPromptOverridesChange={onPromptOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click Assignments tab
|
||||
await user.click(screen.getByTestId("tab-assignments"));
|
||||
expect(screen.getByTestId("tab-assignments")).toHaveClass(/active/);
|
||||
expect(screen.getByTestId("tab-templates")).not.toHaveClass(/active/);
|
||||
|
||||
// Click Overrides tab
|
||||
await user.click(screen.getByTestId("tab-overrides"));
|
||||
expect(screen.getByTestId("tab-overrides")).toHaveClass(/active/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Templates Tab", () => {
|
||||
it("renders built-in templates as read-only cards", () => {
|
||||
render(
|
||||
<AgentPromptsManager
|
||||
value={defaultConfig}
|
||||
onChange={onChange}
|
||||
promptOverrides={{}}
|
||||
onPromptOverridesChange={onPromptOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should show built-in templates section
|
||||
expect(screen.getByTestId("builtin-templates")).toBeTruthy();
|
||||
expect(screen.getByTestId("builtin-template-default-executor")).toBeTruthy();
|
||||
expect(screen.getByTestId("builtin-template-default-triage")).toBeTruthy();
|
||||
|
||||
// Should show Built-in badge
|
||||
expect(screen.getAllByText("Built-in").length).toBe(4);
|
||||
});
|
||||
|
||||
it("built-in template cards have role badges", () => {
|
||||
render(
|
||||
<AgentPromptsManager
|
||||
value={defaultConfig}
|
||||
onChange={onChange}
|
||||
promptOverrides={{}}
|
||||
onPromptOverridesChange={onPromptOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText("Executor Agent").length).toBe(1);
|
||||
expect(screen.getAllByText("Triage Agent").length).toBe(1);
|
||||
expect(screen.getAllByText("Reviewer Agent").length).toBe(1);
|
||||
expect(screen.getAllByText("Merger Agent").length).toBe(1);
|
||||
});
|
||||
|
||||
it("shows custom templates section", () => {
|
||||
render(
|
||||
<AgentPromptsManager
|
||||
value={defaultConfig}
|
||||
onChange={onChange}
|
||||
promptOverrides={{}}
|
||||
onPromptOverridesChange={onPromptOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("custom-templates")).toBeTruthy();
|
||||
expect(screen.getByText("No custom templates yet. Create one to get started.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows Add Custom Template button", () => {
|
||||
render(
|
||||
<AgentPromptsManager
|
||||
value={defaultConfig}
|
||||
onChange={onChange}
|
||||
promptOverrides={{}}
|
||||
onPromptOverridesChange={onPromptOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("add-template-btn")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking Add Custom Template shows editor form", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentPromptsManager
|
||||
value={defaultConfig}
|
||||
onChange={onChange}
|
||||
promptOverrides={{}}
|
||||
onPromptOverridesChange={onPromptOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("add-template-btn"));
|
||||
|
||||
expect(screen.getByTestId("template-editor")).toBeTruthy();
|
||||
expect(screen.getByTestId("template-name-input")).toBeTruthy();
|
||||
expect(screen.getByTestId("template-description-input")).toBeTruthy();
|
||||
expect(screen.getByTestId("template-role-select")).toBeTruthy();
|
||||
expect(screen.getByTestId("template-prompt-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("creating a custom template fires onChange with new template", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentPromptsManager
|
||||
value={defaultConfig}
|
||||
onChange={onChange}
|
||||
promptOverrides={{}}
|
||||
onPromptOverridesChange={onPromptOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Open editor
|
||||
await user.click(screen.getByTestId("add-template-btn"));
|
||||
|
||||
// Fill in the form
|
||||
await user.type(screen.getByTestId("template-name-input"), "My Custom Template");
|
||||
await user.type(screen.getByTestId("template-description-input"), "A custom description");
|
||||
await user.type(screen.getByTestId("template-prompt-input"), "Custom prompt text");
|
||||
|
||||
// Save
|
||||
await user.click(screen.getByTestId("save-template-btn"));
|
||||
|
||||
// Verify onChange was called with new template
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
const newConfig = onChange.mock.calls[0][0];
|
||||
expect(newConfig.templates).toBeDefined();
|
||||
expect(newConfig.templates.length).toBe(1);
|
||||
expect(newConfig.templates[0].name).toBe("My Custom Template");
|
||||
expect(newConfig.templates[0].id).toBe("my-custom-template");
|
||||
});
|
||||
|
||||
it("shows existing custom templates", () => {
|
||||
const config: AgentPromptsConfig = {
|
||||
templates: [
|
||||
{
|
||||
id: "my-custom",
|
||||
name: "My Custom",
|
||||
description: "Custom description",
|
||||
role: "executor",
|
||||
prompt: "Custom prompt",
|
||||
builtIn: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(
|
||||
<AgentPromptsManager
|
||||
value={config}
|
||||
onChange={onChange}
|
||||
promptOverrides={{}}
|
||||
onPromptOverridesChange={onPromptOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("custom-template-my-custom")).toBeTruthy();
|
||||
expect(screen.getAllByText("Custom").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("edit button shows editor with existing template data", async () => {
|
||||
const user = userEvent.setup();
|
||||
const config: AgentPromptsConfig = {
|
||||
templates: [
|
||||
{
|
||||
id: "my-custom",
|
||||
name: "My Custom",
|
||||
description: "Custom description",
|
||||
role: "executor",
|
||||
prompt: "Custom prompt",
|
||||
builtIn: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(
|
||||
<AgentPromptsManager
|
||||
value={config}
|
||||
onChange={onChange}
|
||||
promptOverrides={{}}
|
||||
onPromptOverridesChange={onPromptOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click edit button
|
||||
await user.click(screen.getByTestId("edit-my-custom"));
|
||||
|
||||
// Should show editor with existing data
|
||||
expect(screen.getByTestId("template-editor")).toBeTruthy();
|
||||
expect((screen.getByTestId("template-name-input") as HTMLInputElement).value).toBe("My Custom");
|
||||
});
|
||||
|
||||
it("delete button requires confirmation", async () => {
|
||||
const user = userEvent.setup();
|
||||
const config: AgentPromptsConfig = {
|
||||
templates: [
|
||||
{
|
||||
id: "my-custom",
|
||||
name: "My Custom",
|
||||
description: "Custom description",
|
||||
role: "executor",
|
||||
prompt: "Custom prompt",
|
||||
builtIn: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(
|
||||
<AgentPromptsManager
|
||||
value={config}
|
||||
onChange={onChange}
|
||||
promptOverrides={{}}
|
||||
onPromptOverridesChange={onPromptOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click delete button
|
||||
await user.click(screen.getByTestId("delete-my-custom"));
|
||||
|
||||
// Should show confirmation
|
||||
expect(screen.getByTestId("confirm-delete-my-custom")).toBeTruthy();
|
||||
expect(screen.getByTestId("cancel-delete-my-custom")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("confirming delete fires onChange with template removed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const config: AgentPromptsConfig = {
|
||||
templates: [
|
||||
{
|
||||
id: "my-custom",
|
||||
name: "My Custom",
|
||||
description: "Custom description",
|
||||
role: "executor",
|
||||
prompt: "Custom prompt",
|
||||
builtIn: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(
|
||||
<AgentPromptsManager
|
||||
value={config}
|
||||
onChange={onChange}
|
||||
promptOverrides={{}}
|
||||
onPromptOverridesChange={onPromptOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click delete and confirm
|
||||
await user.click(screen.getByTestId("delete-my-custom"));
|
||||
await user.click(screen.getByTestId("confirm-delete-my-custom"));
|
||||
|
||||
// Verify onChange was called with empty templates
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
const newConfig = onChange.mock.calls[0][0];
|
||||
expect(newConfig.templates).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Assignments Tab", () => {
|
||||
it("renders assignment rows for each core role", () => {
|
||||
render(
|
||||
<AgentPromptsManager
|
||||
value={defaultConfig}
|
||||
onChange={onChange}
|
||||
promptOverrides={{}}
|
||||
onPromptOverridesChange={onPromptOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click Assignments tab
|
||||
fireEvent.click(screen.getByTestId("tab-assignments"));
|
||||
|
||||
expect(screen.getByTestId("assignment-executor")).toBeTruthy();
|
||||
expect(screen.getByTestId("assignment-triage")).toBeTruthy();
|
||||
expect(screen.getByTestId("assignment-reviewer")).toBeTruthy();
|
||||
expect(screen.getByTestId("assignment-merger")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows dropdown with template options", () => {
|
||||
render(
|
||||
<AgentPromptsManager
|
||||
value={defaultConfig}
|
||||
onChange={onChange}
|
||||
promptOverrides={{}}
|
||||
onPromptOverridesChange={onPromptOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click Assignments tab
|
||||
fireEvent.click(screen.getByTestId("tab-assignments"));
|
||||
|
||||
// Executor dropdown should have built-in and "Use default" options
|
||||
const executorSelect = screen.getByTestId("select-executor") as HTMLSelectElement;
|
||||
expect(executorSelect.options.length).toBeGreaterThan(1); // "Use default" + built-in templates for executor
|
||||
});
|
||||
|
||||
it("changing assignment fires onChange", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentPromptsManager
|
||||
value={defaultConfig}
|
||||
onChange={onChange}
|
||||
promptOverrides={{}}
|
||||
onPromptOverridesChange={onPromptOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click Assignments tab
|
||||
fireEvent.click(screen.getByTestId("tab-assignments"));
|
||||
|
||||
// Select a template
|
||||
const select = screen.getByTestId("select-executor");
|
||||
fireEvent.change(select, { target: { value: "default-executor" } });
|
||||
|
||||
// Verify onChange was called
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
const newConfig = onChange.mock.calls[0][0];
|
||||
expect(newConfig.roleAssignments).toBeDefined();
|
||||
expect(newConfig.roleAssignments?.executor).toBe("default-executor");
|
||||
});
|
||||
|
||||
it("clearing assignment removes it from config", async () => {
|
||||
const user = userEvent.setup();
|
||||
const config: AgentPromptsConfig = {
|
||||
roleAssignments: {
|
||||
executor: "default-executor",
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<AgentPromptsManager
|
||||
value={config}
|
||||
onChange={onChange}
|
||||
promptOverrides={{}}
|
||||
onPromptOverridesChange={onPromptOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click Assignments tab
|
||||
fireEvent.click(screen.getByTestId("tab-assignments"));
|
||||
|
||||
// Select "Use default"
|
||||
const select = screen.getByTestId("select-executor");
|
||||
fireEvent.change(select, { target: { value: "" } });
|
||||
|
||||
// Verify onChange was called with executor removed
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
const newConfig = onChange.mock.calls[0][0];
|
||||
expect(newConfig.roleAssignments?.executor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Overrides Tab", () => {
|
||||
it("renders override accordion items", () => {
|
||||
render(
|
||||
<AgentPromptsManager
|
||||
value={defaultConfig}
|
||||
onChange={onChange}
|
||||
promptOverrides={{}}
|
||||
onPromptOverridesChange={onPromptOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click Overrides tab
|
||||
fireEvent.click(screen.getByTestId("tab-overrides"));
|
||||
|
||||
expect(screen.getByTestId("override-executor-welcome")).toBeTruthy();
|
||||
expect(screen.getByTestId("override-triage-welcome")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("accordion items are collapsed by default", () => {
|
||||
render(
|
||||
<AgentPromptsManager
|
||||
value={defaultConfig}
|
||||
onChange={onChange}
|
||||
promptOverrides={{}}
|
||||
onPromptOverridesChange={onPromptOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click Overrides tab
|
||||
fireEvent.click(screen.getByTestId("tab-overrides"));
|
||||
|
||||
// Editor should not be visible
|
||||
expect(screen.queryByTestId("override-input-executor-welcome")).toBeNull();
|
||||
});
|
||||
|
||||
it("clicking expand button shows editor", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentPromptsManager
|
||||
value={defaultConfig}
|
||||
onChange={onChange}
|
||||
promptOverrides={{}}
|
||||
onPromptOverridesChange={onPromptOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click Overrides tab
|
||||
fireEvent.click(screen.getByTestId("tab-overrides"));
|
||||
|
||||
// Click expand button
|
||||
await user.click(screen.getByTestId("expand-executor-welcome"));
|
||||
|
||||
// Editor should now be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("override-input-executor-welcome")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("editing an override fires onPromptOverridesChange", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentPromptsManager
|
||||
value={defaultConfig}
|
||||
onChange={onChange}
|
||||
promptOverrides={{}}
|
||||
onPromptOverridesChange={onPromptOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click Overrides tab
|
||||
fireEvent.click(screen.getByTestId("tab-overrides"));
|
||||
|
||||
// Expand and edit
|
||||
await user.click(screen.getByTestId("expand-executor-welcome"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("override-input-executor-welcome")).toBeTruthy();
|
||||
});
|
||||
|
||||
const textarea = screen.getByTestId("override-input-executor-welcome") as HTMLTextAreaElement;
|
||||
|
||||
// Use fireEvent.change for a single programmatic change
|
||||
fireEvent.change(textarea, { target: { value: "Custom override" } });
|
||||
|
||||
// Verify onPromptOverridesChange was called
|
||||
await waitFor(() => {
|
||||
expect(onPromptOverridesChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check the last call contains the expected value
|
||||
const lastCall = onPromptOverridesChange.mock.calls[onPromptOverridesChange.mock.calls.length - 1];
|
||||
const newOverrides = lastCall[0];
|
||||
expect(newOverrides["executor-welcome"]).toBe("Custom override");
|
||||
});
|
||||
|
||||
it("shows Reset button for existing overrides", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentPromptsManager
|
||||
value={defaultConfig}
|
||||
onChange={onChange}
|
||||
promptOverrides={{
|
||||
"executor-welcome": "Custom text",
|
||||
}}
|
||||
onPromptOverridesChange={onPromptOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click Overrides tab
|
||||
fireEvent.click(screen.getByTestId("tab-overrides"));
|
||||
|
||||
// Expand
|
||||
await user.click(screen.getByTestId("expand-executor-welcome"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("override-input-executor-welcome")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Should show "customized" badge and Reset button
|
||||
expect(screen.getByText("customized")).toBeTruthy();
|
||||
expect(screen.getByTestId("reset-executor-welcome")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Reset button fires onPromptOverridesChange with null", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentPromptsManager
|
||||
value={defaultConfig}
|
||||
onChange={onChange}
|
||||
promptOverrides={{
|
||||
"executor-welcome": "Custom text",
|
||||
}}
|
||||
onPromptOverridesChange={onPromptOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click Overrides tab
|
||||
fireEvent.click(screen.getByTestId("tab-overrides"));
|
||||
|
||||
// Expand and reset
|
||||
await user.click(screen.getByTestId("expand-executor-welcome"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("override-input-executor-welcome")).toBeTruthy();
|
||||
});
|
||||
|
||||
await user.click(screen.getByTestId("reset-executor-welcome"));
|
||||
|
||||
// Verify onPromptOverridesChange was called with null
|
||||
expect(onPromptOverridesChange).toHaveBeenCalledTimes(1);
|
||||
const newOverrides = onPromptOverridesChange.mock.calls[0][0];
|
||||
expect(newOverrides["executor-welcome"]).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2779,7 +2779,7 @@ describe("Prompts section", () => {
|
||||
expect(screen.getAllByText("Prompts").length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("shows prompt override editor when Prompts section is selected", async () => {
|
||||
it("shows AgentPromptsManager when Prompts section is selected", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
@@ -2789,84 +2789,75 @@ describe("Prompts section", () => {
|
||||
// Should show scope banner (project-scoped)
|
||||
expect(screen.getByText("These settings only affect this project.")).toBeTruthy();
|
||||
|
||||
// Should show the info note
|
||||
expect(screen.getByText(/Customize specific segments/)).toBeTruthy();
|
||||
|
||||
// Should show at least one prompt key (from PROMPT_KEY_CATALOG)
|
||||
// The catalog includes keys like "executor-welcome", "triage-welcome", etc.
|
||||
expect(screen.getByText("executor-welcome")).toBeTruthy();
|
||||
// Should show the AgentPromptsManager with tabs
|
||||
expect(screen.getByTestId("tab-templates")).toBeTruthy();
|
||||
expect(screen.getByTestId("tab-assignments")).toBeTruthy();
|
||||
expect(screen.getByTestId("tab-overrides")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders prompt entries with name, key, and description from catalog", async () => {
|
||||
it("shows built-in templates in Templates tab", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||||
|
||||
// Should show prompt names from the catalog
|
||||
expect(screen.getByText("Executor Welcome")).toBeTruthy();
|
||||
expect(screen.getByText("Executor Guardrails")).toBeTruthy();
|
||||
// Templates tab should be active by default
|
||||
expect(screen.getByTestId("tab-templates")).toHaveClass(/active/);
|
||||
|
||||
// Should show prompt keys as code
|
||||
expect(screen.getByText("executor-welcome")).toBeTruthy();
|
||||
|
||||
// Should show descriptions (multiple elements may match)
|
||||
expect(screen.getAllByText(/Introductory section/).length).toBeGreaterThan(0);
|
||||
// Should show built-in templates
|
||||
expect(screen.getByTestId("builtin-template-default-executor")).toBeTruthy();
|
||||
expect(screen.getByTestId("builtin-template-default-triage")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows textarea for each prompt entry", async () => {
|
||||
it("shows Assignments tab with role dropdowns", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||||
|
||||
// Should have textareas for prompt editing
|
||||
const textareas = screen.getAllByRole("textbox");
|
||||
expect(textareas.length).toBeGreaterThan(0);
|
||||
// Click Assignments tab
|
||||
fireEvent.click(screen.getByTestId("tab-assignments"));
|
||||
|
||||
// Should have aria-labels for each prompt
|
||||
expect(screen.getByLabelText(/Executor Welcome prompt override/i)).toBeTruthy();
|
||||
// Should show role assignment rows
|
||||
expect(screen.getByTestId("assignment-executor")).toBeTruthy();
|
||||
expect(screen.getByTestId("assignment-triage")).toBeTruthy();
|
||||
expect(screen.getByTestId("assignment-reviewer")).toBeTruthy();
|
||||
expect(screen.getByTestId("assignment-merger")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows placeholder text with default content hint", async () => {
|
||||
it("shows Overrides tab with accordion items", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||||
|
||||
// Should show hints about default content
|
||||
const hintElements = screen.getAllByText(/No override set/);
|
||||
expect(hintElements.length).toBeGreaterThan(0);
|
||||
// Click Overrides tab
|
||||
fireEvent.click(screen.getByTestId("tab-overrides"));
|
||||
|
||||
// Should show override items (collapsed by default)
|
||||
expect(screen.getByTestId("override-executor-welcome")).toBeTruthy();
|
||||
expect(screen.getByTestId("override-triage-welcome")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows customized badge and Reset button when override exists", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
promptOverrides: {
|
||||
"executor-welcome": "Custom override text",
|
||||
},
|
||||
it("editing a prompt override includes override in save payload", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||||
|
||||
// Click Overrides tab
|
||||
fireEvent.click(screen.getByTestId("tab-overrides"));
|
||||
|
||||
// Expand the executor-welcome override by clicking the expand button
|
||||
fireEvent.click(screen.getByTestId("expand-executor-welcome"));
|
||||
|
||||
// Wait for the expanded editor to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("override-input-executor-welcome")).toBeTruthy();
|
||||
});
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||||
|
||||
// Should show "customized" badge
|
||||
expect(screen.getByText("customized")).toBeTruthy();
|
||||
|
||||
// Should show Reset button
|
||||
expect(screen.getByText("Reset")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("editing a prompt textarea includes override in save payload", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||||
|
||||
// Find the textarea for executor-welcome
|
||||
const textarea = screen.getByLabelText(/Executor Welcome prompt override/i) as HTMLTextAreaElement;
|
||||
const textarea = screen.getByTestId("override-input-executor-welcome") as HTMLTextAreaElement;
|
||||
expect(textarea).toBeTruthy();
|
||||
|
||||
// Type custom content
|
||||
@@ -2896,8 +2887,19 @@ describe("Prompts section", () => {
|
||||
|
||||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||||
|
||||
// Click Overrides tab
|
||||
fireEvent.click(screen.getByTestId("tab-overrides"));
|
||||
|
||||
// Expand the executor-welcome override by clicking the expand button
|
||||
fireEvent.click(screen.getByTestId("expand-executor-welcome"));
|
||||
|
||||
// Wait for the expanded editor to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("override-input-executor-welcome")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Find and click the Reset button
|
||||
fireEvent.click(screen.getByText("Reset"));
|
||||
fireEvent.click(screen.getByTestId("reset-executor-welcome"));
|
||||
|
||||
// Save
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
@@ -2915,8 +2917,19 @@ describe("Prompts section", () => {
|
||||
|
||||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||||
|
||||
// Click Overrides tab
|
||||
fireEvent.click(screen.getByTestId("tab-overrides"));
|
||||
|
||||
// Expand the executor-welcome override by clicking the expand button
|
||||
fireEvent.click(screen.getByTestId("expand-executor-welcome"));
|
||||
|
||||
// Wait for the expanded editor to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("override-input-executor-welcome")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Find the textarea and type content
|
||||
const textarea = screen.getByLabelText(/Executor Welcome prompt override/i) as HTMLTextAreaElement;
|
||||
const textarea = screen.getByTestId("override-input-executor-welcome") as HTMLTextAreaElement;
|
||||
fireEvent.change(textarea, { target: { value: "Custom message" } });
|
||||
|
||||
// Save
|
||||
@@ -2935,12 +2948,11 @@ describe("Prompts section", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("shows Reset button only for prompts with existing overrides", async () => {
|
||||
it("shows customized badge and Reset button for existing overrides", async () => {
|
||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
promptOverrides: {
|
||||
"executor-welcome": "Custom text",
|
||||
"triage-welcome": "Another custom",
|
||||
"executor-welcome": "Custom override text",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2949,8 +2961,71 @@ describe("Prompts section", () => {
|
||||
|
||||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||||
|
||||
// Should have exactly 2 Reset buttons (one for each override)
|
||||
const resetButtons = screen.getAllByText("Reset");
|
||||
expect(resetButtons.length).toBe(2);
|
||||
// Click Overrides tab
|
||||
fireEvent.click(screen.getByTestId("tab-overrides"));
|
||||
|
||||
// Expand the executor-welcome override by clicking the expand button
|
||||
const expandBtn = screen.getByTestId("expand-executor-welcome");
|
||||
fireEvent.click(expandBtn);
|
||||
|
||||
// Wait for the expanded editor to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("override-input-executor-welcome")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Should show "customized" badge
|
||||
expect(screen.getByText("customized")).toBeTruthy();
|
||||
|
||||
// Should show Reset button
|
||||
expect(screen.getByTestId("reset-executor-welcome")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("can create a custom template", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||||
|
||||
// Templates tab should be active by default
|
||||
expect(screen.getByTestId("tab-templates")).toHaveClass(/active/);
|
||||
|
||||
// Click "Add Custom Template" button
|
||||
fireEvent.click(screen.getByTestId("add-template-btn"));
|
||||
|
||||
// Should show the template editor
|
||||
expect(screen.getByTestId("template-editor")).toBeTruthy();
|
||||
expect(screen.getByTestId("template-name-input")).toBeTruthy();
|
||||
expect(screen.getByTestId("template-description-input")).toBeTruthy();
|
||||
expect(screen.getByTestId("template-role-select")).toBeTruthy();
|
||||
expect(screen.getByTestId("template-prompt-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("saving with agentPrompts includes it in the save payload", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getAllByText("Prompts")[0]);
|
||||
|
||||
// Click "Add Custom Template" button
|
||||
fireEvent.click(screen.getByTestId("add-template-btn"));
|
||||
|
||||
// Fill in the template
|
||||
fireEvent.change(screen.getByTestId("template-name-input"), { target: { value: "My Custom Template" } });
|
||||
fireEvent.change(screen.getByTestId("template-description-input"), { target: { value: "A custom template description" } });
|
||||
fireEvent.change(screen.getByTestId("template-prompt-input"), { target: { value: "Custom prompt text" } });
|
||||
|
||||
// Save the template
|
||||
fireEvent.click(screen.getByTestId("save-template-btn"));
|
||||
|
||||
// Save the settings
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
// Verify the payload contains agentPrompts with the custom template
|
||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.agentPrompts).toBeDefined();
|
||||
expect(payload.agentPrompts.templates).toBeDefined();
|
||||
expect(payload.agentPrompts.templates.length).toBe(1);
|
||||
expect(payload.agentPrompts.templates[0].name).toBe("My Custom Template");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user