feat(FN-3033): align agent asset directory naming and heartbeat path compat

The merge delivers three major features: a droid CLI path reconciliation extension that resolves workspace path mismatches for spawned agents, session-first quick chat with improved heartbeat prompts and a dramatically simplified QuickChatFAB component, and canonical agent asset directory naming wit

Fusion-Task-Id: FN-3033
This commit is contained in:
Fusion
2026-05-01 12:51:23 -07:00
committed by gsxdsm
parent 95cd107523
commit a34c819514
11 changed files with 392 additions and 36 deletions

View File

@@ -3,6 +3,11 @@ import { mkdtemp, rm, mkdir, writeFile, readFile, access } from "node:fs/promise
import { join } from "node:path";
import { tmpdir } from "node:os";
import { AgentStore } from "../agent-store.js";
import {
getCanonicalAgentInstructionsBundleDirName,
getLegacyAgentInstructionsBundleDirName,
getSafeAgentAssetIdSegment,
} from "../types.js";
describe("AgentStore — instructions bundle", () => {
let testDir: string;
@@ -62,7 +67,9 @@ describe("AgentStore — instructions bundle", () => {
it("getInstructionsDir returns the managed bundle directory path", async () => {
const agent = await store.createAgent({ name: "dir-agent", role: "executor" });
createdAgentIds.push(agent.id);
expect(store.getInstructionsDir(agent.id)).toBe(join(testDir, "agents", `${agent.id}-instructions`));
expect(store.getInstructionsDir(agent.id)).toBe(
join(testDir, "agents", getCanonicalAgentInstructionsBundleDirName(agent.name, agent.id)),
);
});
it("listBundleFiles returns empty for missing directory and sorted .md files only", async () => {
@@ -273,4 +280,27 @@ describe("AgentStore — instructions bundle", () => {
expect(migrated.instructionsText).toBeUndefined();
expect(migrated.instructionsPath).toBeUndefined();
});
it("uses existing legacy id-only instructions directory when present", async () => {
const agent = await store.createAgent({ name: "Legacy Bundle", role: "executor" });
createdAgentIds.push(agent.id);
const legacyDir = join(testDir, "agents", getLegacyAgentInstructionsBundleDirName(agent.id));
await mkdir(legacyDir, { recursive: true });
await writeFile(join(legacyDir, "AGENTS.md"), "legacy content", "utf-8");
await expect(store.readBundleFile(agent.id, "AGENTS.md")).resolves.toBe("legacy content");
});
it("uses previously-created display-name instructions directory for same id", async () => {
const agent = await store.createAgent({ name: "Current Name", role: "executor" });
createdAgentIds.push(agent.id);
const priorDirName = `previous-name-${getSafeAgentAssetIdSegment(agent.id)}-instructions`;
const priorDir = join(testDir, "agents", priorDirName);
await mkdir(priorDir, { recursive: true });
await writeFile(join(priorDir, "AGENTS.md"), "existing display path", "utf-8");
await expect(store.readBundleFile(agent.id, "AGENTS.md")).resolves.toBe("existing display path");
});
});

View File

@@ -18,7 +18,12 @@ import { join } from "node:path";
import { mkdtempSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { createHash } from "node:crypto";
import { CheckoutConflictError, type AgentCapability, type AgentState } from "../types.js";
import {
CheckoutConflictError,
getCanonicalAgentAssetDirectoryName,
type AgentCapability,
type AgentState,
} from "../types.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "fn-agent-store-test-"));
@@ -150,6 +155,27 @@ describe("AgentStore", () => {
expect(new Date(agent.updatedAt).getTime()).not.toBeNaN();
});
it("defaults heartbeat procedure path to canonical display-name directory", async () => {
const agent = await store.createAgent({
name: "CEO",
role: "executor",
});
const expectedDir = getCanonicalAgentAssetDirectoryName(agent.name, agent.id);
expect(agent.heartbeatProcedurePath).toBe(`.fusion/agents/${expectedDir}/HEARTBEAT.md`);
});
it("falls back to id-based segment when display-name slug is empty", async () => {
const agent = await store.createAgent({
name: "!!!",
role: "executor",
});
const expectedDir = getCanonicalAgentAssetDirectoryName(agent.name, agent.id);
expect(expectedDir).toContain("agent-");
expect(agent.heartbeatProcedurePath).toBe(`.fusion/agents/${expectedDir}/HEARTBEAT.md`);
});
it("preserves custom metadata", async () => {
const agent = await store.createAgent({
name: "With Meta",

View File

@@ -38,7 +38,19 @@ import type {
Task,
AgentLogEntry,
} from "./types.js";
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath } from "./types.js";
import {
AGENT_VALID_TRANSITIONS,
agentToConfigSnapshot,
diffConfigSnapshots,
isEphemeralAgent,
CheckoutConflictError,
DEFAULT_HEARTBEAT_PROCEDURE_PATH,
getDefaultHeartbeatProcedurePath,
getCanonicalAgentInstructionsBundleDirName,
getLegacyAgentAssetDirectoryName,
getLegacyAgentInstructionsBundleDirName,
getSafeAgentAssetIdSegment,
} from "./types.js";
import type { RunMutationContext } from "./types.js";
import type { TaskStore } from "./store.js";
import { computeAccessState } from "./agent-permissions.js";
@@ -262,7 +274,7 @@ export class AgentStore extends EventEmitter {
continue;
}
const newRelPath = getDefaultHeartbeatProcedurePath(agent.id);
const newRelPath = await this.resolveCompatibleHeartbeatProcedurePath(agent);
const newAbsPath = join(this.rootDir, "..", newRelPath);
// Best-effort copy of operator edits to the new per-agent location.
@@ -468,7 +480,7 @@ export class AgentStore extends EventEmitter {
// don't need persistent procedure files.
const ephemeral = isEphemeralAgent({ metadata, name: input.name, role: input.role, reportsTo: input.reportsTo });
const resolvedHeartbeatProcedurePath = input.heartbeatProcedurePath
?? (ephemeral ? undefined : getDefaultHeartbeatProcedurePath(agentId));
?? (ephemeral ? undefined : getDefaultHeartbeatProcedurePath(agentId, input.name));
const agent: Agent = {
id: agentId,
@@ -742,7 +754,9 @@ export class AgentStore extends EventEmitter {
* Does not create the directory.
*/
getInstructionsDir(agentId: string): string {
return this.getBundleDir(agentId);
const agent = this.readAgent(agentId);
const agentName = agent?.name ?? "";
return join(this.agentsDir, getCanonicalAgentInstructionsBundleDirName(agentName, agentId));
}
/**
@@ -750,7 +764,7 @@ export class AgentStore extends EventEmitter {
* Returns [] when the bundle directory does not exist.
*/
async listBundleFiles(agentId: string): Promise<string[]> {
const bundleDir = this.getBundleDir(agentId);
const bundleDir = await this.resolveCompatibleBundleDir(agentId, false);
try {
const entries = await readdir(bundleDir, { withFileTypes: true });
@@ -771,7 +785,8 @@ export class AgentStore extends EventEmitter {
*/
async readBundleFile(agentId: string, filePath: string): Promise<string> {
this.validateBundleFilePath(filePath);
const resolvedPath = join(this.getBundleDir(agentId), filePath);
const bundleDir = await this.resolveCompatibleBundleDir(agentId, false);
const resolvedPath = join(bundleDir, filePath);
return readFile(resolvedPath, "utf-8");
}
@@ -782,7 +797,7 @@ export class AgentStore extends EventEmitter {
return this.withLock(agentId, async () => {
this.validateBundleFilePath(filePath);
const bundleDir = this.getBundleDir(agentId);
const bundleDir = await this.resolveCompatibleBundleDir(agentId, true);
await mkdir(bundleDir, { recursive: true });
const existingFiles = await this.listBundleFiles(agentId);
@@ -804,7 +819,8 @@ export class AgentStore extends EventEmitter {
async deleteBundleFile(agentId: string, filePath: string): Promise<void> {
return this.withLock(agentId, async () => {
this.validateBundleFilePath(filePath);
await unlink(join(this.getBundleDir(agentId), filePath));
const bundleDir = await this.resolveCompatibleBundleDir(agentId, false);
await unlink(join(bundleDir, filePath));
});
}
@@ -831,7 +847,7 @@ export class AgentStore extends EventEmitter {
const updated = await this.updateAgent(agentId, { bundleConfig: normalizedConfig });
if (normalizedConfig.mode === "managed") {
await mkdir(this.getBundleDir(agentId), { recursive: true });
await mkdir(await this.resolveCompatibleBundleDir(agentId, true), { recursive: true });
}
return updated;
@@ -860,7 +876,7 @@ export class AgentStore extends EventEmitter {
});
}
await mkdir(this.getBundleDir(agentId), { recursive: true });
await mkdir(await this.resolveCompatibleBundleDir(agentId, true), { recursive: true });
const files: string[] = [];
@@ -2057,8 +2073,108 @@ export class AgentStore extends EventEmitter {
return null;
}
private getBundleDir(agentId: string): string {
return join(this.agentsDir, `${agentId}-instructions`);
private getCanonicalBundleDir(agent: Agent): string {
return join(this.agentsDir, getCanonicalAgentInstructionsBundleDirName(agent.name, agent.id));
}
private getLegacyBundleDir(agentId: string): string {
return join(this.agentsDir, getLegacyAgentInstructionsBundleDirName(agentId));
}
private async resolveCompatibleBundleDir(agentId: string, createIfMissing: boolean): Promise<string> {
const agent = this.readAgent(agentId);
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
const canonicalDir = this.getCanonicalBundleDir(agent);
if (await this.pathExists(canonicalDir)) {
return canonicalDir;
}
const compatibleDir = await this.findExistingDisplayNameBundleDir(agent);
if (compatibleDir) {
return compatibleDir;
}
const legacyDir = this.getLegacyBundleDir(agent.id);
if (await this.pathExists(legacyDir)) {
return legacyDir;
}
return createIfMissing ? canonicalDir : canonicalDir;
}
private async findExistingDisplayNameBundleDir(agent: Agent): Promise<string | null> {
const safeId = getSafeAgentAssetIdSegment(agent.id);
try {
const entries = await readdir(this.agentsDir, { withFileTypes: true });
const candidates = entries
.filter((entry) => entry.isDirectory() && entry.name.endsWith("-instructions"))
.map((entry) => entry.name)
.filter((name) => {
const base = name.slice(0, -"-instructions".length);
return base.endsWith(`-${safeId}`);
})
.sort((a, b) => a.localeCompare(b));
if (candidates.length === 0) {
return null;
}
const canonicalName = getCanonicalAgentInstructionsBundleDirName(agent.name, agent.id);
const selected = candidates.find((candidate) => candidate === canonicalName) ?? candidates[0];
return join(this.agentsDir, selected);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw err;
}
}
private async resolveCompatibleHeartbeatProcedurePath(agent: Agent): Promise<string> {
const canonicalPath = getDefaultHeartbeatProcedurePath(agent.id, agent.name);
const canonicalAbs = join(this.rootDir, "..", canonicalPath);
if (await this.pathExists(canonicalAbs)) {
return canonicalPath;
}
const safeId = getSafeAgentAssetIdSegment(agent.id);
try {
const entries = await readdir(this.agentsDir, { withFileTypes: true });
const compatibleDir = entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.find((name) => name.endsWith(`-${safeId}`));
if (compatibleDir) {
const candidatePath = `.fusion/agents/${compatibleDir}/HEARTBEAT.md`;
if (await this.pathExists(join(this.rootDir, "..", candidatePath))) {
return candidatePath;
}
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err;
}
}
const legacyPath = `.fusion/agents/${getLegacyAgentAssetDirectoryName(agent.id)}/HEARTBEAT.md`;
const legacyAbs = join(this.rootDir, "..", legacyPath);
if (await this.pathExists(legacyAbs)) {
return legacyPath;
}
return canonicalPath;
}
private async pathExists(path: string): Promise<boolean> {
try {
await access(path, fsConstants.F_OK);
return true;
} catch {
return false;
}
}
private validateBundleFilePath(filePath: string): void {

View File

@@ -3263,20 +3263,71 @@ export interface AgentConfigRevision {
*/
export const DEFAULT_HEARTBEAT_PROCEDURE_PATH = ".fusion/HEARTBEAT.md";
function slugifyAgentAssetSegment(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function getSafeAgentAssetIdSegment(agentId: string): string {
const slug = slugifyAgentAssetSegment(agentId);
return slug || "agent";
}
/**
* Compute the canonical per-agent asset directory segment.
*
* Canonical format: `<slugged-display-name>-<safe-agent-id>`.
* Example: `CEO` + `agent2736` => `ceo-agent2736`.
*
* If the display-name slug is empty (for example name has only symbols), the
* id-derived segment is used as the directory prefix so the result is always
* filesystem-safe and non-empty.
*/
export function getCanonicalAgentAssetDirectoryName(agentName: string, agentId: string): string {
if (!agentId || typeof agentId !== "string") {
throw new Error("getCanonicalAgentAssetDirectoryName requires a non-empty agentId");
}
const safeId = getSafeAgentAssetIdSegment(agentId);
const nameSlug = slugifyAgentAssetSegment(agentName ?? "");
const prefix = nameSlug || safeId;
return `${prefix}-${safeId}`;
}
/** Legacy per-agent asset directory segment used by older builds. */
export function getLegacyAgentAssetDirectoryName(agentId: string): string {
if (!agentId || typeof agentId !== "string") {
throw new Error("getLegacyAgentAssetDirectoryName requires a non-empty agentId");
}
return agentId;
}
/** Canonical managed instruction bundle directory name for an agent. */
export function getCanonicalAgentInstructionsBundleDirName(agentName: string, agentId: string): string {
return `${getCanonicalAgentAssetDirectoryName(agentName, agentId)}-instructions`;
}
/** Legacy managed instruction bundle directory name used by older builds. */
export function getLegacyAgentInstructionsBundleDirName(agentId: string): string {
return `${getLegacyAgentAssetDirectoryName(agentId)}-instructions`;
}
/**
* Compute the project-relative default heartbeat procedure file path for a
* given agent. Each agent gets their own editable HEARTBEAT.md so operators
* can tune the per-tick procedure without changes leaking across the team.
*
* The path is laid out under `.fusion/agents/<agentId>/HEARTBEAT.md` so it
* lives alongside any other future per-agent assets and survives agent
* renames (which do not change the immutable agent id).
* The path is laid out under `.fusion/agents/<canonical-agent-dir>/HEARTBEAT.md`.
*/
export function getDefaultHeartbeatProcedurePath(agentId: string): string {
export function getDefaultHeartbeatProcedurePath(agentId: string, agentName?: string): string {
if (!agentId || typeof agentId !== "string") {
throw new Error("getDefaultHeartbeatProcedurePath requires a non-empty agentId");
}
return `.fusion/agents/${agentId}/HEARTBEAT.md`;
const directory = agentName
? getCanonicalAgentAssetDirectoryName(agentName, agentId)
: getLegacyAgentAssetDirectoryName(agentId);
return `.fusion/agents/${directory}/HEARTBEAT.md`;
}
/** Extract trackable config fields from an Agent into a snapshot */