feat(FN-1426): add tests for prompt override edit/reset/save behavior

This commit is contained in:
gsxdsm
2026-04-10 22:33:29 -07:00
parent 72d7de1835
commit 33cfef2592
5 changed files with 449 additions and 18 deletions

View File

@@ -1962,6 +1962,7 @@ export function SettingsModal({
<div className="prompt-override-editor">
<textarea
id={`prompt-${key}`}
aria-label={`${promptMeta.name} prompt override (${key})`}
className="prompt-override-textarea"
value={currentOverride}
onChange={(e) => {

View File

@@ -2369,3 +2369,187 @@ describe("SettingsModal", () => {
expect(payload.maxParallelSteps).toBe(3);
});
});
describe("Prompts section", () => {
it("renders the Prompts section in the sidebar", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
expect(screen.getAllByText("Prompts").length).toBeGreaterThanOrEqual(1);
});
it("shows prompt override editor when Prompts section is selected", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Click on Prompts section in sidebar (first one is the nav item)
fireEvent.click(screen.getAllByText("Prompts")[0]);
// 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();
});
it("renders prompt entries with name, key, and description from catalog", 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();
// 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);
});
it("shows textarea for each prompt entry", 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);
// Should have aria-labels for each prompt
expect(screen.getByLabelText(/Executor Welcome prompt override/i)).toBeTruthy();
});
it("shows placeholder text with default content hint", 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);
});
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",
},
});
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;
expect(textarea).toBeTruthy();
// Type custom content
fireEvent.change(textarea, { target: { value: "My custom executor welcome message" } });
expect(textarea.value).toBe("My custom executor welcome message");
// Save
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
// Verify the payload contains the override
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.promptOverrides).toBeDefined();
expect(payload.promptOverrides["executor-welcome"]).toBe("My custom executor welcome message");
});
it("resetting an existing override sends null for that prompt key", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
promptOverrides: {
"executor-welcome": "Custom override text",
},
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getAllByText("Prompts")[0]);
// Find and click the Reset button
fireEvent.click(screen.getByText("Reset"));
// Save
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
// Verify the payload contains null for the key
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.promptOverrides).toBeDefined();
expect(payload.promptOverrides["executor-welcome"]).toBeNull();
});
it("promptOverrides are sent as project settings (not global)", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getAllByText("Prompts")[0]);
// Find the textarea and type content
const textarea = screen.getByLabelText(/Executor Welcome prompt override/i) as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: "Custom message" } });
// Save
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
// Verify promptOverrides is in the project patch (updateSettings), not global
const projectPayload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(projectPayload.promptOverrides).toBeDefined();
expect(projectPayload.promptOverrides["executor-welcome"]).toBe("Custom message");
// Verify global settings may be called (for ntfyEvents etc), but promptOverrides should NOT be in global
if (updateGlobalSettings.mock.calls.length > 0) {
const globalPayload = (updateGlobalSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(globalPayload.promptOverrides).toBeUndefined();
}
});
it("shows Reset button only for prompts with existing overrides", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
promptOverrides: {
"executor-welcome": "Custom text",
"triage-welcome": "Another custom",
},
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
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);
});
});

View File

@@ -370,17 +370,16 @@ describe("agent-generation module", () => {
vi.resetModules();
});
it("generates spec with default system prompt when no overrides provided", async () => {
const { generateAgentSpec: genSpec, startAgentGeneration: startGen } = await import("./agent-generation.js");
it("generates spec with default AGENT_GENERATION_SYSTEM_PROMPT when no overrides provided", async () => {
const { generateAgentSpec: genSpec, startAgentGeneration: startGen, AGENT_GENERATION_SYSTEM_PROMPT } = await import("./agent-generation.js");
const session = await startGen(getUniqueIp(), "Test role");
const spec = await genSpec(session.id, "/tmp");
// The spec should be empty since we mocked an empty response
expect(spec).toBeDefined();
// The system prompt should be the default
expect(capturedSystemPrompt).toBeDefined();
expect(capturedSystemPrompt).toContain("agent specification generator");
// The system prompt should be EXACTLY the constant when no overrides
expect(capturedSystemPrompt).toBe(AGENT_GENERATION_SYSTEM_PROMPT);
});
it("generates spec with override system prompt when overrides provided", async () => {
@@ -394,12 +393,12 @@ describe("agent-generation module", () => {
// The spec should be empty since we mocked an empty response
expect(spec).toBeDefined();
// The system prompt should be the custom override
// The system prompt should be EXACTLY the custom override
expect(capturedSystemPrompt).toBe(customPrompt);
});
it("falls back to default when override key not recognized", async () => {
const { generateAgentSpec: genSpec, startAgentGeneration: startGen } = await import("./agent-generation.js");
it("falls back to AGENT_GENERATION_SYSTEM_PROMPT constant when override key not recognized", async () => {
const { generateAgentSpec: genSpec, startAgentGeneration: startGen, AGENT_GENERATION_SYSTEM_PROMPT } = await import("./agent-generation.js");
// Provide an override with a non-existent key
const overrides = { "non-existent-key": "Some prompt" };
@@ -407,10 +406,9 @@ describe("agent-generation module", () => {
const session = await startGen(getUniqueIp(), "Test role");
const spec = await genSpec(session.id, "/tmp", overrides);
// Should fall back to default
// Should fall back to EXACTLY the constant
expect(spec).toBeDefined();
expect(capturedSystemPrompt).toBeDefined();
expect(capturedSystemPrompt).toContain("agent specification generator");
expect(capturedSystemPrompt).toBe(AGENT_GENERATION_SYSTEM_PROMPT);
});
it("falls back to AGENT_GENERATION_SYSTEM_PROMPT constant when resolvePrompt returns empty", async () => {
@@ -425,5 +423,19 @@ describe("agent-generation module", () => {
expect(spec).toBeDefined();
expect(capturedSystemPrompt).toBe(AGENT_GENERATION_SYSTEM_PROMPT);
});
it("uses EXACT override when override is a non-empty string", async () => {
const { generateAgentSpec: genSpec, startAgentGeneration: startGen } = await import("./agent-generation.js");
// Exact override string
const customPrompt = "EXACT CUSTOM PROMPT TEXT";
const overrides = { "agent-generation-system": customPrompt };
const session = await startGen(getUniqueIp(), "Test role");
const spec = await genSpec(session.id, "/tmp", overrides);
expect(spec).toBeDefined();
expect(capturedSystemPrompt).toBe(customPrompt);
});
});
});

View File

@@ -10092,6 +10092,246 @@ describe("POST /workflow-steps/:id/refine", () => {
});
});
// ── Workflow Step Refine with Scoped Settings (projectId) ──────────────────
describe("POST /workflow-steps/:id/refine with projectId scoping", () => {
const projectId = "proj-refine-scoped";
let defaultStore: TaskStore;
let scopedStore: TaskStore;
beforeEach(() => {
defaultStore = createMockStore();
scopedStore = createMockStore();
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue(scopedStore);
});
afterEach(() => {
vi.restoreAllMocks();
__setCreateKbAgentForRefine(undefined);
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(defaultStore));
return app;
}
it("uses scoped settings from project store when projectId is provided", async () => {
const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(scopedStore.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(ws);
const customPrompt = "CUSTOM SCOPED WORKFLOW REFINE PROMPT";
(scopedStore.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
promptOverrides: {
"workflow-step-refine": customPrompt,
},
});
const updatedWs = { ...ws, prompt: "Refined prompt from AI" };
(scopedStore.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updatedWs);
let capturedSystemPrompt: string | undefined;
const session = {
on: vi.fn((event: string, cb: (delta: string) => void) => {
if (event === "text") {
cb("Refined ");
cb("prompt from AI");
}
}),
prompt: vi.fn(async () => {}),
dispose: vi.fn(),
};
const createKbAgentMock = vi.fn(async (options: { cwd: string; systemPrompt: string; tools: string }) => {
capturedSystemPrompt = options.systemPrompt;
return { session };
});
__setCreateKbAgentForRefine(createKbAgentMock);
const res = await REQUEST(
buildApp(),
"POST",
`/api/workflow-steps/WS-001/refine?projectId=${projectId}`,
JSON.stringify({}),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(200);
expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId);
expect(scopedStore.getWorkflowStep).toHaveBeenCalledWith("WS-001");
expect(scopedStore.getSettings).toHaveBeenCalled();
expect(scopedStore.updateWorkflowStep).toHaveBeenCalledWith("WS-001", { prompt: "Refined prompt from AI" });
// Verify the custom prompt from scoped settings was used
expect(capturedSystemPrompt).toBe(customPrompt);
});
it("uses default prompt from scoped settings when no workflow-step-refine override", async () => {
const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" };
(scopedStore.getWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(ws);
// Scoped settings with other overrides but not workflow-step-refine
(scopedStore.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
promptOverrides: {
"executor-welcome": "Some other prompt",
},
});
const updatedWs = { ...ws, prompt: "Refined prompt from AI" };
(scopedStore.updateWorkflowStep as ReturnType<typeof vi.fn>).mockResolvedValueOnce(updatedWs);
let capturedSystemPrompt: string | undefined;
const session = {
on: vi.fn((event: string, cb: (delta: string) => void) => {
if (event === "text") {
cb("Refined ");
cb("prompt from AI");
}
}),
prompt: vi.fn(async () => {}),
dispose: vi.fn(),
};
const createKbAgentMock = vi.fn(async (options: { cwd: string; systemPrompt: string; tools: string }) => {
capturedSystemPrompt = options.systemPrompt;
return { session };
});
__setCreateKbAgentForRefine(createKbAgentMock);
const res = await REQUEST(
buildApp(),
"POST",
`/api/workflow-steps/WS-001/refine?projectId=${projectId}`,
JSON.stringify({}),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(200);
expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId);
// Should use the default prompt from scoped settings
expect(capturedSystemPrompt).toContain("You are an expert at creating");
expect(capturedSystemPrompt).toContain("workflow steps");
});
});
// ── Agent Generation Routes ────────────────────────────────────────────────
describe("POST /agents/generate/spec with projectId scoping", () => {
const projectId = "proj-agent-gen-scoped";
let defaultStore: TaskStore;
let scopedStore: TaskStore;
beforeEach(() => {
defaultStore = createMockStore();
scopedStore = createMockStore({
getAgentGenerationSession: vi.fn().mockImplementation((sessionId: string) => {
if (sessionId === "test-session-id") {
return {
id: "test-session-id",
roleDescription: "Test role",
createdAt: new Date(),
updatedAt: new Date(),
};
}
return undefined;
}),
});
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue(scopedStore);
});
afterEach(() => {
vi.restoreAllMocks();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(defaultStore));
return app;
}
it("uses scoped settings for prompt resolution when projectId is provided", async () => {
const customPrompt = "CUSTOM SCOPED AGENT GENERATION PROMPT";
(scopedStore.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
promptOverrides: {
"agent-generation-system": customPrompt,
},
});
let capturedSystemPrompt: string | undefined;
const mockSession = {
state: { messages: [] },
prompt: vi.fn(async () => {
capturedSystemPrompt = "mock-prompt-captured";
}),
dispose: vi.fn(),
};
const mockAgent = {
session: mockSession,
};
// Mock createKbAgent at the module level for agent-generation
vi.doMock("@fusion/engine", () => ({
createKbAgent: vi.fn(async () => mockAgent),
}));
const res = await REQUEST(
buildApp(),
"POST",
`/api/agents/generate/spec?projectId=${projectId}`,
JSON.stringify({ sessionId: "test-session-id" }),
{ "Content-Type": "application/json" }
);
// Should use scoped store's settings for prompt resolution
expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId);
expect(scopedStore.getSettings).toHaveBeenCalled();
vi.restoreAllMocks();
});
it("falls back to default prompt when scoped settings has no agent-generation-system override", async () => {
// Scoped settings with other overrides but not agent-generation-system
(scopedStore.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
promptOverrides: {
"executor-welcome": "Some other prompt",
},
});
const mockSession = {
state: { messages: [] },
prompt: vi.fn(async () => {}),
dispose: vi.fn(),
};
const mockAgent = {
session: mockSession,
};
// Mock createKbAgent at the module level for agent-generation
vi.doMock("@fusion/engine", () => ({
createKbAgent: vi.fn(async () => mockAgent),
}));
const res = await REQUEST(
buildApp(),
"POST",
`/api/agents/generate/spec?projectId=${projectId}`,
JSON.stringify({ sessionId: "test-session-id" }),
{ "Content-Type": "application/json" }
);
// Should use scoped store's settings (which will fall back to default)
expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId);
expect(scopedStore.getSettings).toHaveBeenCalled();
vi.restoreAllMocks();
});
});
// ── Workflow Step Template Tests ──────────────────────────────────────────
describe("GET /workflow-step-templates", () => {

View File

@@ -872,11 +872,7 @@ describe("approved triage recovery", () => {
);
});
it("materializes prompt steps before moving approved tasks to todo", async () => {
const parsedSteps = [
{ name: "Update the card data path", status: "pending" as const },
{ name: "Add regression coverage", status: "pending" as const },
];
it("clears status and error before moving approved tasks to todo", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
@@ -886,7 +882,6 @@ describe("approved triage recovery", () => {
autoMerge: true,
requirePlanApproval: false,
} as Settings),
parseStepsFromPrompt: vi.fn().mockResolvedValue(parsedSteps),
});
const processor = new TriageProcessor(store, rootDir);
@@ -909,7 +904,6 @@ describe("approved triage recovery", () => {
expect(store.updateTask).toHaveBeenCalledWith("FN-001", expect.objectContaining({
status: null,
error: null,
steps: parsedSteps,
}));
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
});