feat(FN-1050): add per-agent custom instructions support

- Add instructionsPath and instructionsText fields to Agent type and AgentStore
- Create agent-instructions resolver module in engine with priority-based resolution
- Wire custom instructions into executor, triage, reviewer, and merger agents
- Add PATCH /agents/:id/instructions API endpoint with file and text support
- Add instructions editor UI to dashboard agent detail config tab
- Add comprehensive tests for instructions resolver and AgentStore integration
- Add changeset for published package bump
This commit is contained in:
gsxdsm
2026-04-07 15:17:08 -07:00
parent f34c86345c
commit 0f0ddc02fb
14 changed files with 775 additions and 13 deletions

View File

@@ -0,0 +1,197 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { AgentStore } from "../agent-store.js";
describe("AgentStore — instructions fields", () => {
let testDir: string;
let store: AgentStore;
beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), "agent-instructions-test-"));
store = new AgentStore({ rootDir: testDir });
await store.init();
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
});
it("creates an agent with instructionsText", async () => {
const agent = await store.createAgent({
name: "test-agent",
role: "executor",
instructionsText: "Always use TypeScript strict mode.",
});
expect(agent.instructionsText).toBe("Always use TypeScript strict mode.");
expect(agent.instructionsPath).toBeUndefined();
});
it("creates an agent with instructionsPath", async () => {
const agent = await store.createAgent({
name: "test-agent",
role: "executor",
instructionsPath: ".fusion/agents/custom.md",
});
expect(agent.instructionsPath).toBe(".fusion/agents/custom.md");
expect(agent.instructionsText).toBeUndefined();
});
it("creates an agent with both instructionsText and instructionsPath", async () => {
const agent = await store.createAgent({
name: "test-agent",
role: "reviewer",
instructionsText: "Check for security issues.",
instructionsPath: ".fusion/agents/reviewer.md",
});
expect(agent.instructionsText).toBe("Check for security issues.");
expect(agent.instructionsPath).toBe(".fusion/agents/reviewer.md");
});
it("creates an agent without instructions (default)", async () => {
const agent = await store.createAgent({
name: "test-agent",
role: "executor",
});
expect(agent.instructionsText).toBeUndefined();
expect(agent.instructionsPath).toBeUndefined();
});
it("persists instructionsText through roundtrip", async () => {
const created = await store.createAgent({
name: "test-agent",
role: "executor",
instructionsText: "Always write tests.",
});
const loaded = await store.getAgent(created.id);
expect(loaded).not.toBeNull();
expect(loaded!.instructionsText).toBe("Always write tests.");
});
it("persists instructionsPath through roundtrip", async () => {
const created = await store.createAgent({
name: "test-agent",
role: "executor",
instructionsPath: ".fusion/agents/instructions.md",
});
const loaded = await store.getAgent(created.id);
expect(loaded).not.toBeNull();
expect(loaded!.instructionsPath).toBe(".fusion/agents/instructions.md");
});
it("updates instructionsText on an existing agent", async () => {
const agent = await store.createAgent({
name: "test-agent",
role: "executor",
});
const updated = await store.updateAgent(agent.id, {
instructionsText: "Use functional programming patterns.",
});
expect(updated.instructionsText).toBe("Use functional programming patterns.");
});
it("updates instructionsPath on an existing agent", async () => {
const agent = await store.createAgent({
name: "test-agent",
role: "executor",
});
const updated = await store.updateAgent(agent.id, {
instructionsPath: ".fusion/agents/new-instructions.md",
});
expect(updated.instructionsPath).toBe(".fusion/agents/new-instructions.md");
});
it("clears instructionsText by updating to empty string", async () => {
const agent = await store.createAgent({
name: "test-agent",
role: "executor",
instructionsText: "Some instructions",
});
const updated = await store.updateAgent(agent.id, {
instructionsText: "",
});
// Empty string should be persisted as-is (the engine resolver treats empty as no-op)
expect(updated.instructionsText).toBe("");
});
it("clears instructionsPath by updating to empty string", async () => {
const agent = await store.createAgent({
name: "test-agent",
role: "executor",
instructionsPath: ".fusion/agents/old.md",
});
const updated = await store.updateAgent(agent.id, {
instructionsPath: "",
});
expect(updated.instructionsPath).toBe("");
});
it("updates both instructions fields simultaneously", async () => {
const agent = await store.createAgent({
name: "test-agent",
role: "merger",
instructionsText: "Old text",
instructionsPath: "old.md",
});
const updated = await store.updateAgent(agent.id, {
instructionsText: "New text",
instructionsPath: ".fusion/agents/new.md",
});
expect(updated.instructionsText).toBe("New text");
expect(updated.instructionsPath).toBe(".fusion/agents/new.md");
// Verify persistence
const loaded = await store.getAgent(agent.id);
expect(loaded!.instructionsText).toBe("New text");
expect(loaded!.instructionsPath).toBe(".fusion/agents/new.md");
});
it("preserves other fields when updating instructions", async () => {
const agent = await store.createAgent({
name: "test-agent",
role: "executor",
title: "My Executor",
instructionsText: "Initial",
});
const updated = await store.updateAgent(agent.id, {
instructionsText: "Updated",
});
expect(updated.name).toBe("test-agent");
expect(updated.role).toBe("executor");
expect(updated.title).toBe("My Executor");
expect(updated.instructionsText).toBe("Updated");
});
it("roundtrips instructions through getCachedAgent", async () => {
const agent = await store.createAgent({
name: "test-agent",
role: "executor",
instructionsText: "Cached instructions",
instructionsPath: ".fusion/cached.md",
});
const cached = store.getCachedAgent(agent.id);
expect(cached).not.toBeNull();
expect(cached!.instructionsText).toBe("Cached instructions");
expect(cached!.instructionsPath).toBe(".fusion/cached.md");
});
});