feat(FN-1085): align agent routing and runtime contracts
- Harden core AgentStore lifecycle behavior and heartbeat runtime integration paths - Align dashboard agent APIs, server routes, and agent UI flows with the updated contract - Tighten CLI agent/message command routing and validate payload handling semantics - Expand test coverage across core, dashboard, engine, and CLI for route, heartbeat, and instruction regressions
This commit is contained in:
@@ -1175,6 +1175,9 @@ function ConfigTab({
|
||||
if (rc.heartbeatTimeoutMs !== undefined && rc.heartbeatTimeoutMs !== null) {
|
||||
initial.heartbeatTimeoutMs = String(rc.heartbeatTimeoutMs);
|
||||
}
|
||||
if (rc.maxConcurrentRuns !== undefined && rc.maxConcurrentRuns !== null) {
|
||||
initial.maxConcurrentRuns = String(rc.maxConcurrentRuns);
|
||||
}
|
||||
if (rc.messageResponseMode === "immediate" || rc.messageResponseMode === "on-heartbeat") {
|
||||
initial.messageResponseMode = rc.messageResponseMode;
|
||||
}
|
||||
@@ -1202,7 +1205,7 @@ function ConfigTab({
|
||||
}
|
||||
// Check heartbeat values
|
||||
const rc = agent.runtimeConfig ?? {};
|
||||
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "messageResponseMode"] as const) {
|
||||
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns", "messageResponseMode"] as const) {
|
||||
const current = heartbeatValues[key]?.trim() ?? "";
|
||||
const persisted = rc[key] !== undefined && rc[key] !== null ? String(rc[key]) : "";
|
||||
if (current !== persisted) return true;
|
||||
@@ -1251,6 +1254,7 @@ function ConfigTab({
|
||||
for (const [key, config] of Object.entries({
|
||||
heartbeatIntervalMs: { label: "Heartbeat Interval", min: 1000 },
|
||||
heartbeatTimeoutMs: { label: "Heartbeat Timeout", min: 5000 },
|
||||
maxConcurrentRuns: { label: "Max Concurrent Runs", min: 1 },
|
||||
})) {
|
||||
const raw = heartbeatValues[key]?.trim();
|
||||
if (!raw) continue;
|
||||
@@ -1289,7 +1293,7 @@ function ConfigTab({
|
||||
|
||||
// Build the runtimeConfig payload — only include non-empty values
|
||||
const newRuntimeConfig: Record<string, unknown> = { ...agent.runtimeConfig };
|
||||
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs"] as const) {
|
||||
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns"] as const) {
|
||||
const raw = heartbeatValues[key]?.trim();
|
||||
if (!raw) {
|
||||
delete newRuntimeConfig[key];
|
||||
@@ -1420,6 +1424,24 @@ function ConfigTab({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="hb-maxConcurrentRuns">Max Concurrent Runs</label>
|
||||
<input
|
||||
id="hb-maxConcurrentRuns"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className={cn("input", !!errors.maxConcurrentRuns && "input--error")}
|
||||
placeholder="1"
|
||||
value={heartbeatValues.maxConcurrentRuns ?? ""}
|
||||
onChange={(e) => handleHeartbeatFieldChange("maxConcurrentRuns", e.target.value)}
|
||||
/>
|
||||
{errors.maxConcurrentRuns ? (
|
||||
<span className="config-error">{errors.maxConcurrentRuns}</span>
|
||||
) : (
|
||||
<span className="config-hint">Maximum simultaneous heartbeat runs for this agent. Leave empty for system default (1).</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="hb-messageResponseMode">Message Response Mode</label>
|
||||
<select
|
||||
|
||||
@@ -165,6 +165,25 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
void loadAgents();
|
||||
}, [loadAgents]);
|
||||
|
||||
// Refresh agent list on SSE events (independent from useAgents hook state)
|
||||
useEffect(() => {
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const es = new EventSource(`/api/events${query}`);
|
||||
|
||||
const refresh = () => {
|
||||
void loadAgents();
|
||||
};
|
||||
|
||||
es.addEventListener("agent:created", refresh);
|
||||
es.addEventListener("agent:updated", refresh);
|
||||
es.addEventListener("agent:deleted", refresh);
|
||||
es.addEventListener("agent:stateChanged", refresh);
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
};
|
||||
}, [projectId, loadAgents]);
|
||||
|
||||
const handleStateChange = async (agentId: string, newState: AgentState) => {
|
||||
try {
|
||||
await updateAgentState(agentId, newState, projectId);
|
||||
@@ -253,8 +272,11 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
}
|
||||
const lastHeartbeat = new Date(agent.lastHeartbeatAt).getTime();
|
||||
const elapsed = Date.now() - lastHeartbeat;
|
||||
const timeoutMs = 60000; // 60 second timeout
|
||||
if (elapsed > timeoutMs) {
|
||||
const runtimeConfig = agent.runtimeConfig as Record<string, unknown> | undefined;
|
||||
const configuredTimeout = typeof runtimeConfig?.heartbeatTimeoutMs === "number"
|
||||
? runtimeConfig.heartbeatTimeoutMs
|
||||
: 60000;
|
||||
if (elapsed > configuredTimeout) {
|
||||
return { label: "Unresponsive", icon: <Activity size={14} />, color: "var(--state-error-text)" };
|
||||
}
|
||||
return { label: "Healthy", icon: <Heart size={14} />, color: "var(--state-active-text)" };
|
||||
|
||||
@@ -78,6 +78,9 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
const [title, setTitle] = useState("");
|
||||
const [icon, setIcon] = useState("");
|
||||
const [role, setRole] = useState<AgentCapability>("custom");
|
||||
const [reportsTo, setReportsTo] = useState("");
|
||||
const [instructionsPath, setInstructionsPath] = useState("");
|
||||
const [instructionsText, setInstructionsText] = useState("");
|
||||
const [runtimeConfig, setRuntimeConfig] = useState<RuntimeConfig>({
|
||||
model: "",
|
||||
thinkingLevel: "off",
|
||||
@@ -175,6 +178,9 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
setTitle("");
|
||||
setIcon("");
|
||||
setRole("custom");
|
||||
setReportsTo("");
|
||||
setInstructionsPath("");
|
||||
setInstructionsText("");
|
||||
setRuntimeConfig({ model: "", thinkingLevel: "off", maxTurns: 10 });
|
||||
setSelectedPresetId(null);
|
||||
setError(null);
|
||||
@@ -196,6 +202,9 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
role,
|
||||
...(title.trim() ? { title: title.trim() } : {}),
|
||||
...(icon.trim() ? { icon: icon.trim() } : {}),
|
||||
...(reportsTo.trim() ? { reportsTo: reportsTo.trim() } : {}),
|
||||
...(instructionsPath.trim() ? { instructionsPath: instructionsPath.trim() } : {}),
|
||||
...(instructionsText.trim() ? { instructionsText: instructionsText.trim() } : {}),
|
||||
...(Object.keys(runtimeCfg).length > 0 ? { runtimeConfig: runtimeCfg } : {}),
|
||||
}, projectId);
|
||||
handleClose();
|
||||
@@ -303,6 +312,39 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-reports-to">Reports To <span className="agent-dialog-optional">(optional agent ID)</span></label>
|
||||
<input
|
||||
id="agent-reports-to"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="e.g. agent-1234abcd"
|
||||
value={reportsTo}
|
||||
onChange={e => setReportsTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-instructions-path">Instructions Path <span className="agent-dialog-optional">(optional)</span></label>
|
||||
<input
|
||||
id="agent-instructions-path"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="e.g. .fusion/agents/reviewer.md"
|
||||
value={instructionsPath}
|
||||
onChange={e => setInstructionsPath(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-instructions-text">Inline Instructions <span className="agent-dialog-optional">(optional)</span></label>
|
||||
<textarea
|
||||
id="agent-instructions-text"
|
||||
className="input"
|
||||
rows={4}
|
||||
placeholder="Add custom behavior instructions..."
|
||||
value={instructionsText}
|
||||
onChange={e => setInstructionsText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* AI-assisted generation */}
|
||||
<div className="agent-dialog-ai-generate">
|
||||
<button
|
||||
@@ -394,6 +436,24 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
<span className="agent-dialog-summary-row-label">Role</span>
|
||||
<span>{selectedRole?.icon} {selectedRole?.label}</span>
|
||||
</div>
|
||||
{reportsTo.trim() && (
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span className="agent-dialog-summary-row-label">Reports To</span>
|
||||
<span>{reportsTo.trim()}</span>
|
||||
</div>
|
||||
)}
|
||||
{instructionsPath.trim() && (
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span className="agent-dialog-summary-row-label">Instructions File</span>
|
||||
<span>{instructionsPath.trim()}</span>
|
||||
</div>
|
||||
)}
|
||||
{instructionsText.trim() && (
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span className="agent-dialog-summary-row-label">Inline Instructions</span>
|
||||
<span>{instructionsText.trim().length} chars</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span className="agent-dialog-summary-row-label">Model</span>
|
||||
<span>
|
||||
|
||||
Reference in New Issue
Block a user