feat(FN-2962): merge fusion/fn-2962

- Add changeset for `@runfusion/fusion` minor release introducing custom provider registration support

Commits merged:
- feat(FN-2962): complete Step 8 — add changeset and documentation

Files changed:
.changeset/register-custom-providers.md | 5 +++++
 1 file changed, 5 insertions(+)

Fusion-Task-Id: FN-2962
This commit is contained in:
Fusion
2026-04-29 21:42:44 -07:00
committed by gsxdsm
parent 476d17e2ca
commit 38f5159719
33 changed files with 1048 additions and 60 deletions

View File

@@ -4229,6 +4229,17 @@ export function updateAgent(agentId: string, updates: AgentUpdateInput, projectI
});
}
/** Backfill an existing agent onto the default heartbeat procedure file. */
export function upgradeAgentHeartbeatProcedure(
agentId: string,
projectId?: string,
): Promise<{ agent: Agent; heartbeatProcedurePath: string; procedureFileSeeded: boolean }> {
return api(
withProjectId(`/agents/${encodeURIComponent(agentId)}/upgrade-heartbeat-procedure`, projectId),
{ method: "POST" },
);
}
/** Update agent custom instructions */
export function updateAgentInstructions(
agentId: string,

View File

@@ -9,7 +9,7 @@ import {
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, upgradeAgentHeartbeatProcedure } from "../api";
import type { Agent } from "../api";
import type { AgentLogEntry, Task } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
@@ -2509,14 +2509,93 @@ function deriveBudgetValues(runtimeConfig: AgentDetail["runtimeConfig"] | undefi
return nextValues;
}
function ConfigTab({
function HeartbeatProcedureSection({
agent,
projectId,
addToast,
onSaved,
}: {
agent: AgentDetail;
projectId?: string;
addToast: (message: string, type?: "success" | "error") => void;
onSaved: () => Promise<void>;
}) {
const [isUpgrading, setIsUpgrading] = useState(false);
const currentPath = agent.heartbeatProcedurePath?.trim();
const onDefault = currentPath === ".fusion/HEARTBEAT.md";
const handleUpgrade = async () => {
setIsUpgrading(true);
try {
const result = await upgradeAgentHeartbeatProcedure(agent.id, projectId);
addToast(
result.procedureFileSeeded
? `Heartbeat procedure file ready at ${result.heartbeatProcedurePath}`
: `Heartbeat procedure path set to ${result.heartbeatProcedurePath}`,
"success",
);
await onSaved();
} catch (err) {
addToast(`Failed to upgrade heartbeat procedure: ${getErrorMessage(err)}`, "error");
} finally {
setIsUpgrading(false);
}
};
return (
<div className="config-section">
<h3>Heartbeat Procedure</h3>
<p className="config-description">
The per-tick procedure this agent runs every wake. Defaults to a project-level
markdown file you can edit. Resets on every tick no need to restart the agent
after editing.
</p>
<div className="config-fields">
<div className="config-field">
<span className="config-hint">
Current path: <code>{currentPath || "(none — using built-in default)"}</code>
</span>
</div>
<div className="config-field">
<button
className="btn"
disabled={isUpgrading || onDefault}
onClick={() => void handleUpgrade()}
aria-label="Upgrade agent to default heartbeat procedure file"
>
{isUpgrading ? (
<>
<Loader2 size={16} className="animate-spin" />
Upgrading
</>
) : onDefault ? (
<>
<CheckCircle size={16} />
Already on default
</>
) : (
"Upgrade to Default Heartbeat Procedure"
)}
</button>
<span className="config-hint">
Sets <code>heartbeatProcedurePath</code> to <code>.fusion/HEARTBEAT.md</code>
{" "}and seeds the file from the built-in template if it doesn't exist.
Operator edits to the file are preserved.
</span>
</div>
</div>
</div>
);
}
function ConfigTab({
agent,
projectId,
addToast,
onSaved,
onHasChangesChange,
onDelete,
}: {
}: {
agent: AgentDetail;
projectId?: string;
addToast: (message: string, type?: "success" | "error") => void;
@@ -3603,6 +3682,13 @@ function ConfigTab({
</div>
</div>
<HeartbeatProcedureSection
agent={agent}
projectId={projectId}
addToast={addToast}
onSaved={onSaved}
/>
<div className="config-section config-section--danger">
<h3>Danger Zone</h3>
<p className="config-description">

View File

@@ -746,7 +746,14 @@ export function ModelOnboardingModal({
const loadCustomProviders = useCallback(async () => {
try {
const data = await fetchCustomProviders();
setCustomProviders(data.providers ?? []);
setCustomProviders((data.providers ?? []).map((provider) => ({
id: provider.id,
name: provider.name,
baseUrl: provider.baseUrl,
api: provider.apiType === "anthropic-compatible" ? "anthropic-messages" : "openai-completions",
apiKey: provider.apiKey,
models: (provider.models ?? []).map((model) => ({ id: model.id, name: model.name })),
})));
} catch {
// best effort
}

View File

@@ -786,7 +786,14 @@ export function SettingsModal({
if (activeSection === "authentication") {
setAuthLoading(true);
loadAuthStatus().finally(() => setAuthLoading(false));
void fetchCustomProviders().then((data) => setCustomProviders(data.providers ?? [])).catch(() => undefined);
void fetchCustomProviders().then((data) => setCustomProviders((data.providers ?? []).map((provider) => ({
id: provider.id,
name: provider.name,
baseUrl: provider.baseUrl,
api: provider.apiType === "anthropic-compatible" ? "anthropic-messages" : "openai-completions",
apiKey: provider.apiKey,
models: (provider.models ?? []).map((model) => ({ id: model.id, name: model.name })),
})))).catch(() => undefined);
}
// Clean up polling when leaving auth section
return () => {
@@ -799,7 +806,14 @@ export function SettingsModal({
const loadCustomProviders = useCallback(async () => {
const data = await fetchCustomProviders();
setCustomProviders(data.providers ?? []);
setCustomProviders((data.providers ?? []).map((provider) => ({
id: provider.id,
name: provider.name,
baseUrl: provider.baseUrl,
api: provider.apiType === "anthropic-compatible" ? "anthropic-messages" : "openai-completions",
apiKey: provider.apiKey,
models: (provider.models ?? []).map((model) => ({ id: model.id, name: model.name })),
})));
}, []);
const handleSaveCustomProvider = useCallback(async (config: CustomProviderConfig) => {

View File

@@ -125,6 +125,21 @@ function getInProgressElapsedMs(task: Task, nowMs: number): number | null {
return Math.max(0, nowMs - startedMs);
}
// Wall-clock end-to-end runtime: from when the task first entered in-progress
// to when it first entered done (or `now` if not yet done). Preferred over the
// instrumented `[timing]` sum on cards in in-progress / in-review / done so the
// timer reflects how long the task actually took, not just the time spent
// inside instrumented code paths. Returns null on legacy tasks that completed
// before `executionStartedAt` was tracked, so callers can fall back.
function getEndToEndDurationMs(task: Task, nowMs: number): number | null {
const startedMs = parseTimestampToMs(task.executionStartedAt);
if (startedMs == null) return null;
const completedMs = parseTimestampToMs(task.executionCompletedAt);
const endMs = completedMs != null && completedMs >= startedMs ? completedMs : nowMs;
return Math.max(0, endMs - startedMs);
}
// Mirrors summarizeWorkflowTiming in TaskTokenStatsPanel: completed steps use
// completedAt-startedAt; in-progress steps contribute live elapsed (now-startedAt).
function getWorkflowRuntimeMs(task: Task, nowMs: number): number | null {
@@ -697,16 +712,18 @@ function TaskCardComponent({
const merging = task.status != null && ACTIVE_MERGE_STATUSES.has(task.status);
if (!merging && task.column === "in-progress") {
const endToEndMs = getEndToEndDurationMs(task, Date.now());
const elapsedMs = getInProgressElapsedMs(task, Date.now());
const instrumentedMs = getInstrumentedDurationMs(task, Date.now());
if (elapsedMs == null && instrumentedMs == null) {
if (endToEndMs == null && elapsedMs == null && instrumentedMs == null) {
return;
}
}
if (!merging && task.column === "in-review") {
const endToEndMs = getEndToEndDurationMs(task, Date.now());
const instrumentedMs = getInstrumentedDurationMs(task, Date.now());
if (instrumentedMs == null) {
if (endToEndMs == null && instrumentedMs == null) {
return;
}
}
@@ -717,7 +734,7 @@ function TaskCardComponent({
}, LIVE_TIME_INDICATOR_POLL_MS);
return () => window.clearInterval(interval);
}, [task.column, task.status, task.columnMovedAt, task.updatedAt, task.workflowStepResults, task.timedExecutionMs]);
}, [task.column, task.status, task.columnMovedAt, task.updatedAt, task.workflowStepResults, task.timedExecutionMs, task.executionStartedAt, task.executionCompletedAt]);
const timeIndicator = useMemo(() => {
if (!TIME_INDICATOR_COLUMNS.has(task.column)) {
@@ -744,8 +761,12 @@ function TaskCardComponent({
}
if (task.column === "in-progress") {
// Prefer the persistent execution start (set on first transition to
// in-progress, never reset on retry-loop bounces). Fall back to the
// columnMovedAt heuristic for legacy tasks predating the new field.
const elapsedMs =
getInProgressElapsedMs(task, timeIndicatorNowMs)
getEndToEndDurationMs(task, timeIndicatorNowMs)
?? getInProgressElapsedMs(task, timeIndicatorNowMs)
?? getInstrumentedDurationMs(task, timeIndicatorNowMs);
if (elapsedMs == null) {
return null;
@@ -756,20 +777,23 @@ function TaskCardComponent({
return null;
}
const hasColumnElapsed = getInProgressElapsedMs(task, timeIndicatorNowMs) != null;
return {
label: elapsedLabel,
title: hasColumnElapsed ? `In progress ${elapsedLabel}` : `Execution time ${elapsedLabel}`,
ariaLabel: hasColumnElapsed ? `In progress ${elapsedLabel}` : `Execution time ${elapsedLabel}`,
title: `In progress ${elapsedLabel}`,
ariaLabel: `In progress ${elapsedLabel}`,
};
}
const instrumentedMs = getInstrumentedDurationMs(task, timeIndicatorNowMs);
if (instrumentedMs == null) {
// in-review and done: show wall-clock end-to-end runtime. Falls back to
// the instrumented `[timing]` aggregate for tasks completed before
// `executionStartedAt`/`executionCompletedAt` were tracked.
const endToEndMs = getEndToEndDurationMs(task, timeIndicatorNowMs);
const totalMs = endToEndMs ?? getInstrumentedDurationMs(task, timeIndicatorNowMs);
if (totalMs == null) {
return null;
}
const elapsedLabel = formatElapsedDurationDone(instrumentedMs);
const elapsedLabel = formatElapsedDurationDone(totalMs);
if (!elapsedLabel) {
return null;
}
@@ -789,7 +813,7 @@ function TaskCardComponent({
title: `Execution time ${elapsedLabel}. Completed ${completedAt}`,
ariaLabel: `Execution time ${elapsedLabel}. Completed ${completedAt}`,
};
}, [task.column, task.status, task.columnMovedAt, task.timedExecutionMs, task.updatedAt, task.workflowStepResults, task.log, timeIndicatorNowMs]);
}, [task.column, task.status, task.columnMovedAt, task.timedExecutionMs, task.updatedAt, task.workflowStepResults, task.log, task.executionStartedAt, task.executionCompletedAt, timeIndicatorNowMs]);
useEffect(() => {
if (!hasGitHubBadge || !isInViewport) {

View File

@@ -8546,6 +8546,40 @@ describe("Git Management endpoints", () => {
});
});
describe("GET /git/changes", () => {
const resetGitRepo = () => {
const { headSha } = getSharedGitTestRepo();
execFileSync("git", ["-C", gitRepoDir, "reset", "--hard", headSha], { stdio: "pipe" });
execFileSync("git", ["-C", gitRepoDir, "clean", "-fd"], { stdio: "pipe" });
};
beforeEach(() => {
resetGitRepo();
});
afterEach(() => {
resetGitRepo();
});
it("preserves the first unstaged entry instead of misclassifying it as staged", async () => {
const readmePath = join(gitRepoDir, "README.md");
const original = readFileSync(readmePath, "utf-8");
const marker = `\nchanges-first-line-${Date.now()}\n`;
writeFileSync(readmePath, `${original}${marker}`);
const res = await GET(buildApp(), "/api/git/changes");
expect(res.status).toBe(200);
expect(res.body).toEqual([
{
file: "README.md",
status: "modified",
staged: false,
},
]);
});
});
describe("GET /git/diff/file", () => {
const resetGitRepo = () => {
const { headSha } = getSharedGitTestRepo();

View File

@@ -1,5 +1,6 @@
import type { Request, Response } from "express";
import type { Agent, AgentCapability, AgentUpdateInput, TaskStore } from "@fusion/core";
import { DEFAULT_HEARTBEAT_PROCEDURE_PATH } from "@fusion/core";
import { ApiError, badRequest, notFound } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js";
@@ -66,6 +67,7 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
soul,
memory,
bundleConfig,
heartbeatProcedurePath,
} = req.body ?? {};
if (!name || typeof name !== "string") {
@@ -107,6 +109,12 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
if (typeof memory === "string" && memory.length > 50000) {
throw badRequest("memory must be at most 50,000 characters");
}
if (heartbeatProcedurePath !== undefined && heartbeatProcedurePath !== null && typeof heartbeatProcedurePath !== "string") {
throw badRequest("heartbeatProcedurePath must be a string");
}
if (typeof heartbeatProcedurePath === "string" && heartbeatProcedurePath.length > 500) {
throw badRequest("heartbeatProcedurePath must be at most 500 characters");
}
if (bundleConfig !== undefined && bundleConfig !== null) {
if (typeof bundleConfig !== "object" || Array.isArray(bundleConfig)) {
throw badRequest("bundleConfig must be an object");
@@ -144,7 +152,21 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
soul: soul ?? undefined,
memory: memory ?? undefined,
bundleConfig: bundleConfig ?? undefined,
heartbeatProcedurePath: heartbeatProcedurePath ?? undefined,
});
// Seed the default heartbeat procedure file if the new agent landed on
// the default path (which createAgent fills in for non-ephemeral agents
// when no override is provided). Idempotent — operator edits are kept.
if (agent.heartbeatProcedurePath === DEFAULT_HEARTBEAT_PROCEDURE_PATH) {
try {
const { ensureDefaultHeartbeatProcedureFile, HEARTBEAT_PROCEDURE } = await import("@fusion/engine");
await ensureDefaultHeartbeatProcedureFile(scopedStore.getRootDir(), DEFAULT_HEARTBEAT_PROCEDURE_PATH, HEARTBEAT_PROCEDURE);
} catch {
// Non-fatal — the heartbeat resolver falls back to the in-memory constant.
}
}
res.status(201).json(agent);
} catch (err: unknown) {
if (err instanceof ApiError) {
@@ -394,6 +416,16 @@ export function registerAgentCoreRoutes(ctx: ApiRoutesContext, deps: AgentCoreRo
updates.memory = body.memory ?? undefined;
}
if ("heartbeatProcedurePath" in body) {
if (body.heartbeatProcedurePath !== null && typeof body.heartbeatProcedurePath !== "string") {
throw badRequest("heartbeatProcedurePath must be a string");
}
if (typeof body.heartbeatProcedurePath === "string" && body.heartbeatProcedurePath.length > 500) {
throw badRequest("heartbeatProcedurePath must be at most 500 characters");
}
updates.heartbeatProcedurePath = body.heartbeatProcedurePath ?? undefined;
}
if ("bundleConfig" in body) {
if (body.bundleConfig !== null) {
if (typeof body.bundleConfig !== "object" || Array.isArray(body.bundleConfig)) {
@@ -436,6 +468,52 @@ export function registerAgentCoreRoutes(ctx: ApiRoutesContext, deps: AgentCoreRo
}
});
/**
* POST /api/agents/:id/upgrade-heartbeat-procedure
* Backfill an existing agent onto the default heartbeat procedure file.
* Sets `heartbeatProcedurePath` to DEFAULT_HEARTBEAT_PROCEDURE_PATH and
* seeds the file with the built-in HEARTBEAT_PROCEDURE if it doesn't exist.
* Idempotent: existing operator edits to the file are preserved.
*/
router.post("/agents/:id/upgrade-heartbeat-procedure", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const existing = await agentStore.getAgent(req.params.id);
if (!existing) {
throw notFound(`agent ${req.params.id} not found`);
}
const { ensureDefaultHeartbeatProcedureFile, HEARTBEAT_PROCEDURE } = await import("@fusion/engine");
const filePath = await ensureDefaultHeartbeatProcedureFile(
scopedStore.getRootDir(),
DEFAULT_HEARTBEAT_PROCEDURE_PATH,
HEARTBEAT_PROCEDURE,
);
const updated = await agentStore.updateAgent(req.params.id, {
heartbeatProcedurePath: DEFAULT_HEARTBEAT_PROCEDURE_PATH,
});
res.json({
agent: updated,
heartbeatProcedurePath: DEFAULT_HEARTBEAT_PROCEDURE_PATH,
procedureFileSeeded: filePath !== null,
});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if ((err instanceof Error ? err.message : String(err)).includes("not found")) {
throw notFound(err instanceof Error ? err.message : String(err));
}
rethrowAsApiError(err);
}
});
/**
* DELETE /api/agents/:id
* Delete an agent.

View File

@@ -717,15 +717,19 @@ export async function dropGitStash(index: number, cwd?: string): Promise<string>
export async function getGitFileChanges(cwd?: string): Promise<GitFileChange[]> {
try {
const output = (await runGitCommand(["status", "--porcelain=v1"], cwd, 5000)).trim();
if (!output) return [];
const output = await runGitCommand(["status", "--porcelain=v1"], cwd, 5000);
if (!output.trim()) return [];
const changes: GitFileChange[] = [];
for (const line of output.split("\n")) {
if (line.length < 3) continue;
const indexStatus = line[0];
const workTreeStatus = line[1];
const filePath = line.slice(3).trim();
// Preserve leading status spaces from porcelain output. Trimming the
// whole command output corrupts the first unstaged entry (`" M foo"` →
// `"M foo"`), which misclassifies it as staged and truncates the path.
const normalizedLine = line.replace(/\r$/, "");
if (normalizedLine.length < 3) continue;
const indexStatus = normalizedLine[0];
const workTreeStatus = normalizedLine[1];
const filePath = normalizedLine.slice(3).trim();
const mapStatus = (code: string): GitFileChange["status"] => {
switch (code) {