feat(FN-1445): add project-scoping and fallback handling for AI routes and missions
- Add projectId parameter to refineText API and TaskForm for proper scoping - Update mission routes to require projectId and add regression tests - Add mission interview fallback to mission-level context when target not found - Add AI refine route scoping tests for project isolation - Update component tests to expect projectId argument - Update prompt-keys test counts for new system prompts
This commit is contained in:
@@ -82,7 +82,7 @@ describe("prompt-overrides", () => {
|
||||
describe("getPromptKeysForRole", () => {
|
||||
it("should return all keys for executor role", () => {
|
||||
const keys = getPromptKeysForRole("executor");
|
||||
expect(keys).toHaveLength(7);
|
||||
expect(keys).toHaveLength(8);
|
||||
expect(keys.map((k) => k.key)).toContain("executor-welcome");
|
||||
expect(keys.map((k) => k.key)).toContain("executor-guardrails");
|
||||
expect(keys.map((k) => k.key)).toContain("executor-spawning");
|
||||
@@ -90,14 +90,16 @@ describe("prompt-overrides", () => {
|
||||
expect(keys.map((k) => k.key)).toContain("agent-generation-system");
|
||||
expect(keys.map((k) => k.key)).toContain("workflow-step-refine");
|
||||
expect(keys.map((k) => k.key)).toContain("subtask-breakdown-system");
|
||||
expect(keys.map((k) => k.key)).toContain("ai-refine-system");
|
||||
});
|
||||
|
||||
it("should return all keys for triage role", () => {
|
||||
const keys = getPromptKeysForRole("triage");
|
||||
expect(keys).toHaveLength(3);
|
||||
expect(keys).toHaveLength(4);
|
||||
expect(keys.map((k) => k.key)).toContain("triage-welcome");
|
||||
expect(keys.map((k) => k.key)).toContain("triage-context");
|
||||
expect(keys.map((k) => k.key)).toContain("planning-system");
|
||||
expect(keys.map((k) => k.key)).toContain("mission-interview-system");
|
||||
});
|
||||
|
||||
it("should return single key for reviewer role", () => {
|
||||
|
||||
@@ -38,7 +38,9 @@ export type PromptKey =
|
||||
| "agent-generation-system"
|
||||
| "workflow-step-refine"
|
||||
| "planning-system"
|
||||
| "subtask-breakdown-system";
|
||||
| "subtask-breakdown-system"
|
||||
| "mission-interview-system"
|
||||
| "ai-refine-system";
|
||||
|
||||
/**
|
||||
* Metadata describing a prompt key including its purpose and default content.
|
||||
@@ -333,6 +335,99 @@ Return ONLY valid JSON in this format:
|
||||
]
|
||||
}`,
|
||||
},
|
||||
"mission-interview-system": {
|
||||
key: "mission-interview-system",
|
||||
name: "Mission Interview System",
|
||||
roles: ["triage"],
|
||||
description: "System prompt for AI-assisted mission planning interviews",
|
||||
defaultContent: `You are a mission planning assistant for a project management system.
|
||||
|
||||
Your job: help users transform high-level goals into structured mission plans with milestones, slices, and features — each with verification criteria.
|
||||
|
||||
## Mission Hierarchy
|
||||
- Mission: The top-level objective (the user will provide this)
|
||||
- Milestone: A major phase or deliverable within the mission (e.g., "Foundation & Infrastructure", "Core Feature Development", "Polish & Release"). Each milestone has verification criteria that define how to confirm the phase is complete.
|
||||
- Slice: A focused work unit within a milestone that can be activated and worked on independently (e.g., "Auth system setup", "API endpoints", "UI components"). Each slice has verification criteria.
|
||||
- Feature: A specific deliverable within a slice, detailed enough to become a task (e.g., "JWT token refresh endpoint", "Password reset email template"). Each feature has acceptance criteria.
|
||||
|
||||
## Conversation Flow
|
||||
1. The user describes their mission goal
|
||||
2. Ask clarifying questions to understand scope, constraints, technical context, user needs, and priorities
|
||||
3. Push back on vague objectives — ask for specifics
|
||||
4. Challenge unrealistic scope — suggest phasing
|
||||
5. Once you have enough information (typically 4-8 questions), produce the structured plan
|
||||
6. The plan should be thorough — break every milestone into slices, every slice into features
|
||||
|
||||
## Question Types to Use
|
||||
- "text": Open-ended questions for detailed input
|
||||
- "single_select": When user must choose one option (e.g., priority, approach)
|
||||
- "multi_select": When multiple options can apply (e.g., features to include, platforms to support)
|
||||
- "confirm": Yes/No questions for quick decisions
|
||||
|
||||
## Guidelines
|
||||
- Start with big-picture scope questions, then narrow into specifics
|
||||
- Ask about target users, key constraints, technical preferences, timeline
|
||||
- Each milestone should represent a meaningful phase boundary or checkpoint
|
||||
- Each slice should be independently shippable work
|
||||
- Features should be specific and actionable
|
||||
- ALWAYS include verification/acceptance criteria at every level:
|
||||
- Milestone: "verification" field — how to confirm this phase is complete (e.g., "All API endpoints return correct responses, integration tests pass")
|
||||
- Slice: "verification" field — how to confirm this work unit is done (e.g., "Auth flow works end-to-end from signup through login")
|
||||
- Feature: "acceptanceCriteria" field — how to verify this specific deliverable (e.g., "JWT tokens expire after 1 hour and refresh correctly")
|
||||
- Suggest sensible defaults and push for specificity
|
||||
- Aim for 2-4 milestones, 1-3 slices per milestone, 2-5 features per slice
|
||||
- Keep the plan realistic and achievable
|
||||
|
||||
## Response Format
|
||||
Always respond with valid JSON in one of these formats:
|
||||
|
||||
For questions:
|
||||
{"type": "question", "data": {"id": "unique-id", "type": "text|single_select|multi_select|confirm", "question": "The question text", "description": "Helpful context", "options": [{"id": "opt1", "label": "Option 1", "description": "Details"}]}}
|
||||
|
||||
For completion (when you have enough information):
|
||||
{"type": "complete", "data": {"missionTitle": "Refined mission title", "missionDescription": "Comprehensive mission description based on the conversation", "milestones": [{"title": "Milestone title", "description": "What this phase achieves", "verification": "How to confirm this milestone is complete", "slices": [{"title": "Slice title", "description": "What this work unit covers", "verification": "How to confirm this slice is done", "features": [{"title": "Feature title", "description": "What to build", "acceptanceCriteria": "How to verify this feature works"}]}]}]}}`,
|
||||
},
|
||||
"ai-refine-system": {
|
||||
key: "ai-refine-system",
|
||||
name: "AI Refine System",
|
||||
roles: ["executor"],
|
||||
description: "System prompt for AI-powered text refinement",
|
||||
defaultContent: `You are a text refinement assistant for a task management system.
|
||||
|
||||
Your job is to refine task descriptions based on the user's selected refinement type.
|
||||
|
||||
## Refinement Types
|
||||
|
||||
1. **clarify**: Make the description clearer and more specific
|
||||
- Remove ambiguity
|
||||
- Add specific details where vague
|
||||
- Ensure the goal is well-defined
|
||||
- Keep approximately the same length
|
||||
|
||||
2. **add-details**: Add implementation details and context
|
||||
- Add technical considerations
|
||||
- Include edge cases to consider
|
||||
- Mention related files/components if apparent
|
||||
- Expand moderately (1.5-2x length)
|
||||
|
||||
3. **expand**: Expand into a more comprehensive description
|
||||
- Add background context
|
||||
- Include acceptance criteria
|
||||
- List specific sub-tasks or steps
|
||||
- Significantly expand (2-3x length)
|
||||
|
||||
4. **simplify**: Simplify and make more concise
|
||||
- Remove redundant words
|
||||
- Use concise language
|
||||
- Keep core meaning intact
|
||||
- Reduce length significantly (0.5-0.7x)
|
||||
|
||||
## Guidelines
|
||||
- Maintain the original intent and meaning
|
||||
- Keep the tone professional and actionable
|
||||
- Output ONLY the refined text, no markdown formatting, no explanations
|
||||
- The output should be a direct replacement for the input text`,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1892,6 +1892,21 @@ describe("refineText", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes projectId as query param for scoped settings resolution", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(
|
||||
mockFetchResponse(true, { refined: "Refined with scoped settings" })
|
||||
);
|
||||
|
||||
const result = await refineText("Original text", "clarify", "proj-123");
|
||||
|
||||
expect(result).toBe("Refined with scoped settings");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/ai/refine-text?projectId=proj-123", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text: "Original text", type: "clarify" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("works with all four refinement types", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(
|
||||
mockFetchResponse(true, { refined: "Refined" })
|
||||
|
||||
@@ -1829,11 +1829,12 @@ export interface RefineTextResponse {
|
||||
* Refine task description text using AI.
|
||||
* @param text - The text to refine (1-2000 characters)
|
||||
* @param type - The refinement type: clarify, add-details, expand, or simplify
|
||||
* @param projectId - Optional project ID for scoped settings resolution
|
||||
* @returns The refined text
|
||||
* @throws Error with message for rate limit (429), invalid type (422), validation (400), or server errors
|
||||
*/
|
||||
export async function refineText(text: string, type: RefinementType): Promise<string> {
|
||||
const response = await api<RefineTextResponse>("/ai/refine-text", {
|
||||
export async function refineText(text: string, type: RefinementType, projectId?: string): Promise<string> {
|
||||
const response = await api<RefineTextResponse>(withProjectId("/ai/refine-text", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text, type }),
|
||||
});
|
||||
|
||||
@@ -919,7 +919,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
setIsRefineMenuOpen(false);
|
||||
setIsRefining(true);
|
||||
try {
|
||||
const refined = await refineText(trimmed, type);
|
||||
const refined = await refineText(trimmed, type, projectId);
|
||||
setDescription(refined);
|
||||
addToast("Description refined with AI", "success");
|
||||
// Auto-resize textarea after content update
|
||||
@@ -933,7 +933,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
} finally {
|
||||
setIsRefining(false);
|
||||
}
|
||||
}, [description, isRefining, addToast]);
|
||||
}, [description, isRefining, addToast, projectId]);
|
||||
|
||||
const truncate = (s: string, len: number) =>
|
||||
s.length > len ? s.slice(0, len) + "…" : s;
|
||||
|
||||
@@ -418,7 +418,7 @@ export function TaskForm({
|
||||
|
||||
setIsRefining(true);
|
||||
try {
|
||||
const refined = await refineText(trimmed, type);
|
||||
const refined = await refineText(trimmed, type, projectId);
|
||||
onDescriptionChange(refined);
|
||||
setIsRefineMenuOpen(false);
|
||||
addToast("Description refined with AI", "success");
|
||||
@@ -432,7 +432,7 @@ export function TaskForm({
|
||||
} finally {
|
||||
setIsRefining(false);
|
||||
}
|
||||
}, [description, isRefining, addToast, onDescriptionChange]);
|
||||
}, [description, isRefining, addToast, onDescriptionChange, projectId]);
|
||||
|
||||
const handleToggleFavorite = useCallback(async (provider: string) => {
|
||||
const currentFavorites = favoriteProviders;
|
||||
|
||||
@@ -1665,7 +1665,7 @@ describe("QuickEntryBox", () => {
|
||||
fireEvent.click(screen.getByTestId("refine-clarify"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refineText).toHaveBeenCalledWith("Original text", "clarify");
|
||||
expect(refineText).toHaveBeenCalledWith("Original text", "clarify", TEST_PROJECT_ID);
|
||||
});
|
||||
|
||||
// Textarea should be updated
|
||||
|
||||
@@ -501,7 +501,8 @@ describe("TaskForm", () => {
|
||||
fireEvent.click(screen.getByTestId("refine-clarify"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(refineText).toHaveBeenCalledWith("Some text to refine", "clarify");
|
||||
// projectId is undefined in this test context
|
||||
expect(refineText).toHaveBeenCalledWith("Some text to refine", "clarify", undefined);
|
||||
expect(onDescriptionChange).toHaveBeenCalledWith("Refined text");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,15 +15,22 @@ import {
|
||||
RATE_LIMIT_WINDOW_MS,
|
||||
} from "./ai-refine.js";
|
||||
|
||||
// Hoisted mock factory
|
||||
const { mockCreateKbAgent } = vi.hoisted(() => ({
|
||||
mockCreateKbAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the engine module to avoid dynamic import issues in tests
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
createKbAgent: vi.fn().mockResolvedValue(null),
|
||||
createKbAgent: mockCreateKbAgent,
|
||||
}));
|
||||
|
||||
describe("ai-refine module", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
__resetRefineState();
|
||||
vi.clearAllMocks();
|
||||
mockCreateKbAgent.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -287,4 +294,98 @@ describe("ai-refine module", () => {
|
||||
expect(checkRateLimit(ip)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prompt override regression", () => {
|
||||
const customPrompt = "You are a custom refine assistant.";
|
||||
|
||||
function createRefineMockAgent(responseText: string) {
|
||||
return {
|
||||
session: {
|
||||
state: {
|
||||
messages: [
|
||||
{ role: "assistant", content: responseText },
|
||||
],
|
||||
},
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("uses default prompt when no overrides provided", async () => {
|
||||
const mockAgent = createRefineMockAgent("Refined text here");
|
||||
mockCreateKbAgent.mockResolvedValueOnce(mockAgent);
|
||||
|
||||
await refineText("some text", "clarify", "/tmp/project");
|
||||
|
||||
expect(mockCreateKbAgent).toHaveBeenCalled();
|
||||
const call = mockCreateKbAgent.mock.calls[0];
|
||||
expect(call[0].systemPrompt).toMatch(/^You are a text refinement assistant/);
|
||||
});
|
||||
|
||||
it("uses override prompt when promptOverrides provided", async () => {
|
||||
const mockAgent = createRefineMockAgent("Refined text here");
|
||||
mockCreateKbAgent.mockResolvedValueOnce(mockAgent);
|
||||
|
||||
await refineText("some text", "clarify", "/tmp/project", {
|
||||
"ai-refine-system": customPrompt,
|
||||
});
|
||||
|
||||
expect(mockCreateKbAgent).toHaveBeenCalled();
|
||||
const call = mockCreateKbAgent.mock.calls[0];
|
||||
expect(call[0].systemPrompt).toBe(customPrompt);
|
||||
});
|
||||
|
||||
it("falls back to default prompt when override is empty string", async () => {
|
||||
const mockAgent = createRefineMockAgent("Refined text here");
|
||||
mockCreateKbAgent.mockResolvedValueOnce(mockAgent);
|
||||
|
||||
await refineText("some text", "simplify", "/tmp/project", {
|
||||
"ai-refine-system": "",
|
||||
});
|
||||
|
||||
expect(mockCreateKbAgent).toHaveBeenCalled();
|
||||
const call = mockCreateKbAgent.mock.calls[0];
|
||||
expect(call[0].systemPrompt).toMatch(/^You are a text refinement assistant/);
|
||||
});
|
||||
|
||||
it("does not introduce unexpected model/provider override fields in createKbAgent", async () => {
|
||||
const mockAgent = createRefineMockAgent("Refined text here");
|
||||
mockCreateKbAgent.mockResolvedValueOnce(mockAgent);
|
||||
|
||||
await refineText("some text", "expand", "/tmp/project", {
|
||||
"ai-refine-system": customPrompt,
|
||||
});
|
||||
|
||||
expect(mockCreateKbAgent).toHaveBeenCalled();
|
||||
const call = mockCreateKbAgent.mock.calls[0];
|
||||
const agentConfig = call[0];
|
||||
|
||||
// Verify only expected fields are present
|
||||
expect(agentConfig).toHaveProperty("cwd");
|
||||
expect(agentConfig).toHaveProperty("systemPrompt");
|
||||
expect(agentConfig).toHaveProperty("tools");
|
||||
|
||||
// Verify no unexpected model override fields
|
||||
expect(agentConfig).not.toHaveProperty("modelProvider");
|
||||
expect(agentConfig).not.toHaveProperty("modelId");
|
||||
expect(agentConfig).not.toHaveProperty("provider");
|
||||
expect(agentConfig).not.toHaveProperty("model");
|
||||
});
|
||||
|
||||
it("prompt overrides do not affect other prompt keys", async () => {
|
||||
const mockAgent = createRefineMockAgent("Refined text here");
|
||||
mockCreateKbAgent.mockResolvedValueOnce(mockAgent);
|
||||
|
||||
// Provide overrides for a different key only
|
||||
await refineText("some text", "clarify", "/tmp/project", {
|
||||
"workflow-step-refine": "Should not affect AI refine",
|
||||
});
|
||||
|
||||
expect(mockCreateKbAgent).toHaveBeenCalled();
|
||||
const call = mockCreateKbAgent.mock.calls[0];
|
||||
// AI refine should use its own default prompt, not affected by workflow-step-refine override
|
||||
expect(call[0].systemPrompt).toMatch(/^You are a text refinement assistant/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,8 +8,12 @@
|
||||
* - Rate limiting per IP (10 requests per hour)
|
||||
* - Dynamic import of @fusion/engine for AI agent creation
|
||||
* - Text length validation (1-2000 characters)
|
||||
* - Prompt override support for project-level customization
|
||||
*/
|
||||
|
||||
import type { PromptOverrideMap } from "@fusion/core";
|
||||
import { resolvePrompt } from "@fusion/core";
|
||||
|
||||
// Dynamic import for @fusion/engine to avoid resolution issues in test environment
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports, @typescript-eslint/no-explicit-any
|
||||
type AgentResult = any;
|
||||
@@ -252,12 +256,14 @@ export function validateRefineRequest(
|
||||
* @param text - The text to refine
|
||||
* @param type - The type of refinement to apply
|
||||
* @param rootDir - Project root directory for AI agent context
|
||||
* @param promptOverrides - Optional prompt overrides from project settings
|
||||
* @returns The refined text
|
||||
*/
|
||||
export async function refineText(
|
||||
text: string,
|
||||
type: RefinementType,
|
||||
rootDir: string
|
||||
rootDir: string,
|
||||
promptOverrides?: PromptOverrideMap,
|
||||
): Promise<string> {
|
||||
// Ensure engine is loaded before using createKbAgent
|
||||
await engineReady;
|
||||
@@ -266,9 +272,11 @@ export async function refineText(
|
||||
throw new AiServiceError("AI engine not available");
|
||||
}
|
||||
|
||||
const effectivePrompt = resolvePrompt("ai-refine-system", promptOverrides);
|
||||
|
||||
const agentResult = await createKbAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt: REFINE_SYSTEM_PROMPT,
|
||||
systemPrompt: effectivePrompt,
|
||||
tools: "readonly",
|
||||
});
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
submitMissionInterviewResponse,
|
||||
} from "./mission-interview.js";
|
||||
import * as missionInterviewModule from "./mission-interview.js";
|
||||
import * as projectStoreResolver from "./project-store-resolver.js";
|
||||
|
||||
// Mock MissionStore factory
|
||||
function createMockMissionStore() {
|
||||
@@ -440,6 +441,7 @@ function createMockStore(): TaskStore {
|
||||
return {
|
||||
getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()),
|
||||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||||
getSettings: vi.fn().mockResolvedValue({ promptOverrides: {} }),
|
||||
pauseTask: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
@@ -2083,7 +2085,8 @@ describe("Mission API", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true, sessionId: "session-1" });
|
||||
expect(retrySpy).toHaveBeenCalledWith("session-1", "/fake/root");
|
||||
// Default store returns {} for promptOverrides when projectId is omitted
|
||||
expect(retrySpy).toHaveBeenCalledWith("session-1", "/fake/root", {});
|
||||
});
|
||||
|
||||
it("returns 404 when interview retry session is missing", async () => {
|
||||
@@ -2323,6 +2326,183 @@ describe("Mission API", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Interview endpoints with projectId scoping ───────────────────────────
|
||||
//
|
||||
// Tests that verify interview endpoints use scoped project context when projectId
|
||||
// is provided, including prompt override resolution from scoped settings.
|
||||
describe("Interview endpoints with projectId scoping", () => {
|
||||
const projectId = "test-project";
|
||||
const scopedRootDir = "/scoped/project/path";
|
||||
|
||||
let scopedStore: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
__resetMissionInterviewState();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
// Create a scoped store mock with settings support
|
||||
scopedStore = {
|
||||
getRootDir: vi.fn().mockReturnValue(scopedRootDir),
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
promptOverrides: {
|
||||
"mission-interview-system": "Scoped mission interview prompt",
|
||||
},
|
||||
}),
|
||||
getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue(scopedStore);
|
||||
});
|
||||
|
||||
it("POST /api/missions/interview/start uses scoped store settings when projectId provided", async () => {
|
||||
const createSpy = vi
|
||||
.spyOn(missionInterviewModule, "createMissionInterviewSession")
|
||||
.mockResolvedValueOnce("scoped-session-id");
|
||||
|
||||
const { app } = buildApp();
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/interview/start?projectId=${projectId}`,
|
||||
JSON.stringify({ missionTitle: "Scoped Mission" }),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId);
|
||||
expect(scopedStore.getRootDir()).toBe(scopedRootDir);
|
||||
expect(scopedStore.getSettings).toHaveBeenCalled();
|
||||
expect(createSpy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
"Scoped Mission",
|
||||
scopedRootDir,
|
||||
{ "mission-interview-system": "Scoped mission interview prompt" },
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /api/missions/interview/respond uses scoped store settings when projectId provided", async () => {
|
||||
const respondSpy = vi
|
||||
.spyOn(missionInterviewModule, "submitMissionInterviewResponse")
|
||||
.mockResolvedValueOnce({
|
||||
type: "question",
|
||||
data: {
|
||||
id: "q-next",
|
||||
type: "text",
|
||||
question: "Next question?",
|
||||
description: "Continue",
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { app } = buildApp();
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/interview/respond?projectId=${projectId}`,
|
||||
JSON.stringify({ sessionId: "scoped-session", responses: { "q-1": "Answer" } }),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId);
|
||||
expect(scopedStore.getSettings).toHaveBeenCalled();
|
||||
expect(respondSpy).toHaveBeenCalledWith(
|
||||
"scoped-session",
|
||||
{ "q-1": "Answer" },
|
||||
scopedRootDir,
|
||||
{ "mission-interview-system": "Scoped mission interview prompt" },
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /api/missions/interview/:sessionId/retry uses scoped store settings when projectId provided", async () => {
|
||||
const retrySpy = vi
|
||||
.spyOn(missionInterviewModule, "retryMissionInterviewSession")
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const { app } = buildApp();
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/interview/scoped-retry/retry?projectId=${projectId}`
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId);
|
||||
expect(scopedStore.getSettings).toHaveBeenCalled();
|
||||
expect(retrySpy).toHaveBeenCalledWith(
|
||||
"scoped-retry",
|
||||
scopedRootDir,
|
||||
{ "mission-interview-system": "Scoped mission interview prompt" },
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /api/missions/interview/start uses default store when projectId is omitted", async () => {
|
||||
const createSpy = vi
|
||||
.spyOn(missionInterviewModule, "createMissionInterviewSession")
|
||||
.mockResolvedValueOnce("default-session-id");
|
||||
|
||||
// When projectId is omitted, getOrCreateProjectStore should not be called
|
||||
// The scoped store spy is still active from beforeEach, so we need to mock it to return undefined
|
||||
vi.mocked(projectStoreResolver.getOrCreateProjectStore).mockRejectedValueOnce(
|
||||
new Error("Should not be called when projectId is omitted")
|
||||
);
|
||||
|
||||
const { app } = buildApp();
|
||||
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/missions/interview/start",
|
||||
JSON.stringify({ missionTitle: "Default Mission" }),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(createSpy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
"Default Mission",
|
||||
"/fake/root",
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 409 lock conflict for interview respond when projectId provided", async () => {
|
||||
// First create the session so it exists
|
||||
const createSpy = vi
|
||||
.spyOn(missionInterviewModule, "createMissionInterviewSession")
|
||||
.mockResolvedValueOnce("locked-session");
|
||||
|
||||
const { app } = buildApp({
|
||||
aiSessionStore: {
|
||||
acquireLock: vi.fn().mockReturnValue({ acquired: false, currentHolder: "other-tab" }),
|
||||
},
|
||||
});
|
||||
|
||||
// Create the session first
|
||||
await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/interview/start?projectId=${projectId}`,
|
||||
JSON.stringify({ missionTitle: "Locked Mission" }),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
|
||||
// Now try to respond - should get 409 due to lock conflict
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
`/api/missions/interview/respond?projectId=${projectId}`,
|
||||
JSON.stringify({ sessionId: "locked-session", responses: { "q-1": "answer" }, tabId: "my-tab" }),
|
||||
{ "content-type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body).toEqual({
|
||||
error: "Session locked by another tab",
|
||||
lockedByTab: "other-tab",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Regression: Generated ID format acceptance ─────────────────────────
|
||||
//
|
||||
// MissionStore.generateMissionId() produces IDs like M-LZ7DN0-A2B5
|
||||
|
||||
@@ -567,4 +567,227 @@ describe("mission-interview module", () => {
|
||||
expect(new InvalidSessionStateError("bad").name).toBe("InvalidSessionStateError");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prompt override regression", () => {
|
||||
const customPrompt = "You are a custom mission interview assistant.";
|
||||
const defaultPromptStart = "You are a mission planning assistant";
|
||||
|
||||
it("uses default prompt when no overrides provided", async () => {
|
||||
const mockAgent = createMockAgent([createQuestionJson()]);
|
||||
mockCreateKbAgent.mockImplementationOnce(async () => mockAgent);
|
||||
|
||||
await createMissionInterviewSession("192.168.1.1", "Test Mission", "/tmp/project");
|
||||
|
||||
await waitForCurrentQuestion(await createMissionInterviewSession("192.168.1.1", "Test Mission 2", "/tmp/project"));
|
||||
|
||||
// The first session starts asynchronously, so we need to wait
|
||||
// Check the createKbAgent call was made with default prompt
|
||||
expect(mockCreateKbAgent).toHaveBeenCalled();
|
||||
const lastCall = mockCreateKbAgent.mock.calls[mockCreateKbAgent.mock.calls.length - 1];
|
||||
expect(lastCall[0].systemPrompt).toMatch(/^You are a mission planning assistant/);
|
||||
});
|
||||
|
||||
it("uses override prompt when promptOverrides provided", async () => {
|
||||
const mockAgent = createMockAgent([createQuestionJson()]);
|
||||
mockCreateKbAgent.mockImplementationOnce(async () => mockAgent);
|
||||
|
||||
const sessionId = await createMissionInterviewSession(
|
||||
"192.168.1.2",
|
||||
"Test Mission",
|
||||
"/tmp/project",
|
||||
{ "mission-interview-system": customPrompt },
|
||||
);
|
||||
await waitForCurrentQuestion(sessionId);
|
||||
|
||||
expect(mockCreateKbAgent).toHaveBeenCalled();
|
||||
const lastCall = mockCreateKbAgent.mock.calls[mockCreateKbAgent.mock.calls.length - 1];
|
||||
expect(lastCall[0].systemPrompt).toBe(customPrompt);
|
||||
});
|
||||
|
||||
it("falls back to default prompt when override is empty string", async () => {
|
||||
const mockAgent = createMockAgent([createQuestionJson()]);
|
||||
mockCreateKbAgent.mockImplementationOnce(async () => mockAgent);
|
||||
|
||||
const sessionId = await createMissionInterviewSession(
|
||||
"192.168.1.3",
|
||||
"Test Mission",
|
||||
"/tmp/project",
|
||||
{ "mission-interview-system": "" },
|
||||
);
|
||||
await waitForCurrentQuestion(sessionId);
|
||||
|
||||
expect(mockCreateKbAgent).toHaveBeenCalled();
|
||||
const lastCall = mockCreateKbAgent.mock.calls[mockCreateKbAgent.mock.calls.length - 1];
|
||||
expect(lastCall[0].systemPrompt).toMatch(/^You are a mission planning assistant/);
|
||||
});
|
||||
|
||||
it("passes prompt overrides through submitMissionInterviewResponse for rehydrated sessions", async () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildMissionRow({ id: "mission-prompt-override-1", status: "awaiting_input" });
|
||||
store.rows.set(row.id, row);
|
||||
setAiSessionStore(store as any);
|
||||
expect(rehydrateFromStore(store as any)).toBe(1);
|
||||
|
||||
const resumedAgent = createMockAgent([createQuestionJson("q-override")]);
|
||||
mockCreateKbAgent.mockImplementationOnce(async () => resumedAgent);
|
||||
|
||||
await submitMissionInterviewResponse(
|
||||
row.id,
|
||||
{ "q-2": "Test response" },
|
||||
"/tmp/project",
|
||||
{ "mission-interview-system": customPrompt },
|
||||
);
|
||||
|
||||
expect(mockCreateKbAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
systemPrompt: customPrompt,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes prompt overrides through retryMissionInterviewSession", async () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildMissionRow({
|
||||
id: "mission-retry-prompt-override",
|
||||
status: "error",
|
||||
error: "Test error",
|
||||
conversationHistory: JSON.stringify([
|
||||
{
|
||||
question: { id: "q-1", type: "text", question: "Goal?", description: "scope" },
|
||||
response: { "q-1": "Build app" },
|
||||
},
|
||||
]),
|
||||
});
|
||||
store.rows.set(row.id, row);
|
||||
setAiSessionStore(store as any);
|
||||
|
||||
const resumedAgent = createMockAgent([createQuestionJson("q-retry")]);
|
||||
mockCreateKbAgent.mockImplementationOnce(async () => resumedAgent);
|
||||
|
||||
await retryMissionInterviewSession(
|
||||
row.id,
|
||||
"/tmp/project",
|
||||
{ "mission-interview-system": customPrompt },
|
||||
);
|
||||
|
||||
expect(mockCreateKbAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
systemPrompt: customPrompt,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not introduce unexpected model/provider override fields in createKbAgent", async () => {
|
||||
const mockAgent = createMockAgent([createQuestionJson()]);
|
||||
mockCreateKbAgent.mockImplementationOnce(async () => mockAgent);
|
||||
|
||||
const sessionId = await createMissionInterviewSession(
|
||||
"192.168.1.4",
|
||||
"Test Mission",
|
||||
"/tmp/project",
|
||||
{ "mission-interview-system": customPrompt },
|
||||
);
|
||||
await waitForCurrentQuestion(sessionId);
|
||||
|
||||
expect(mockCreateKbAgent).toHaveBeenCalled();
|
||||
const lastCall = mockCreateKbAgent.mock.calls[mockCreateKbAgent.mock.calls.length - 1];
|
||||
const agentConfig = lastCall[0];
|
||||
|
||||
// Verify only expected fields are present
|
||||
expect(agentConfig).toHaveProperty("cwd");
|
||||
expect(agentConfig).toHaveProperty("systemPrompt");
|
||||
expect(agentConfig).toHaveProperty("tools");
|
||||
expect(agentConfig).toHaveProperty("onThinking");
|
||||
expect(agentConfig).toHaveProperty("onText");
|
||||
|
||||
// Verify no unexpected model override fields
|
||||
expect(agentConfig).not.toHaveProperty("modelProvider");
|
||||
expect(agentConfig).not.toHaveProperty("modelId");
|
||||
expect(agentConfig).not.toHaveProperty("provider");
|
||||
expect(agentConfig).not.toHaveProperty("model");
|
||||
});
|
||||
|
||||
it("prompt overrides do not affect other prompt keys", async () => {
|
||||
const mockAgent = createMockAgent([createQuestionJson()]);
|
||||
mockCreateKbAgent.mockImplementationOnce(async () => mockAgent);
|
||||
|
||||
// Provide overrides for a different key only
|
||||
const sessionId = await createMissionInterviewSession(
|
||||
"192.168.1.5",
|
||||
"Test Mission",
|
||||
"/tmp/project",
|
||||
{ "planning-system": "Should not affect mission interview" },
|
||||
);
|
||||
await waitForCurrentQuestion(sessionId);
|
||||
|
||||
expect(mockCreateKbAgent).toHaveBeenCalled();
|
||||
const lastCall = mockCreateKbAgent.mock.calls[mockCreateKbAgent.mock.calls.length - 1];
|
||||
// Mission interview should use its own default prompt, not affected by planning-system override
|
||||
expect(lastCall[0].systemPrompt).toMatch(/^You are a mission planning assistant/);
|
||||
});
|
||||
|
||||
it("falls back to default prompt on retry when promptOverrides is undefined", async () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildMissionRow({
|
||||
id: "mission-retry-no-override",
|
||||
status: "error",
|
||||
error: "Test error",
|
||||
conversationHistory: JSON.stringify([
|
||||
{
|
||||
question: { id: "q-1", type: "text", question: "Goal?", description: "scope" },
|
||||
response: { "q-1": "Build app" },
|
||||
},
|
||||
]),
|
||||
});
|
||||
store.rows.set(row.id, row);
|
||||
setAiSessionStore(store as any);
|
||||
|
||||
const resumedAgent = createMockAgent([createQuestionJson("q-retry")]);
|
||||
mockCreateKbAgent.mockImplementationOnce(async () => resumedAgent);
|
||||
|
||||
await retryMissionInterviewSession(row.id, "/tmp/project", undefined);
|
||||
|
||||
expect(mockCreateKbAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
systemPrompt: expect.stringContaining("mission planning assistant"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to default prompt through submitMissionInterviewResponse for rehydrated sessions when overrides undefined", async () => {
|
||||
const store = new MockAiSessionStore();
|
||||
const row = buildMissionRow({ id: "mission-submit-no-override", status: "awaiting_input" });
|
||||
store.rows.set(row.id, row);
|
||||
setAiSessionStore(store as any);
|
||||
expect(rehydrateFromStore(store as any)).toBe(1);
|
||||
|
||||
const resumedAgent = createMockAgent([createQuestionJson("q-fallback")]);
|
||||
mockCreateKbAgent.mockImplementationOnce(async () => resumedAgent);
|
||||
|
||||
await submitMissionInterviewResponse(row.id, { "q-2": "Test" }, "/tmp/project", undefined);
|
||||
|
||||
expect(mockCreateKbAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
systemPrompt: expect.stringContaining("mission planning assistant"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to default prompt when promptOverrides is empty object", async () => {
|
||||
const mockAgent = createMockAgent([createQuestionJson()]);
|
||||
mockCreateKbAgent.mockImplementationOnce(async () => mockAgent);
|
||||
|
||||
const sessionId = await createMissionInterviewSession(
|
||||
"192.168.1.6",
|
||||
"Test Mission",
|
||||
"/tmp/project",
|
||||
{},
|
||||
);
|
||||
await waitForCurrentQuestion(sessionId);
|
||||
|
||||
expect(mockCreateKbAgent).toHaveBeenCalled();
|
||||
const lastCall = mockCreateKbAgent.mock.calls[mockCreateKbAgent.mock.calls.length - 1];
|
||||
expect(lastCall[0].systemPrompt).toMatch(/^You are a mission planning assistant/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
* - Rate limiting per IP
|
||||
* - Session expiration and cleanup
|
||||
* - SSE streaming via MissionInterviewStreamManager
|
||||
* - Prompt override support for project-level customization
|
||||
*/
|
||||
|
||||
import type { PlanningQuestion } from "@fusion/core";
|
||||
import type { PlanningQuestion, PromptOverrideMap } from "@fusion/core";
|
||||
import { resolvePrompt } from "@fusion/core";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { AiSessionStore, AiSessionRow } from "./ai-session-store.js";
|
||||
@@ -688,9 +690,13 @@ function disposeMissionAgentForRetry(session: MissionInterviewSession): void {
|
||||
/**
|
||||
* Initialize the AI agent for a session and start the first turn.
|
||||
*/
|
||||
async function initializeAgent(session: MissionInterviewSession, rootDir: string): Promise<void> {
|
||||
async function initializeAgent(
|
||||
session: MissionInterviewSession,
|
||||
rootDir: string,
|
||||
promptOverrides?: PromptOverrideMap,
|
||||
): Promise<void> {
|
||||
try {
|
||||
session.agent = await createMissionInterviewAgent(session, rootDir);
|
||||
session.agent = await createMissionInterviewAgent(session, rootDir, promptOverrides);
|
||||
session.updatedAt = new Date();
|
||||
|
||||
// Send initial message to get first question
|
||||
@@ -714,12 +720,15 @@ async function initializeAgent(session: MissionInterviewSession, rootDir: string
|
||||
async function createMissionInterviewAgent(
|
||||
session: MissionInterviewSession,
|
||||
rootDir: string,
|
||||
promptOverrides?: PromptOverrideMap,
|
||||
): Promise<AgentResult> {
|
||||
await engineReady;
|
||||
|
||||
const effectivePrompt = resolvePrompt("mission-interview-system", promptOverrides);
|
||||
|
||||
return createKbAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt: MISSION_INTERVIEW_SYSTEM_PROMPT,
|
||||
systemPrompt: effectivePrompt,
|
||||
tools: "readonly",
|
||||
onThinking: (delta: string) => {
|
||||
session.thinkingOutput += delta;
|
||||
@@ -761,6 +770,7 @@ async function ensureMissionInterviewAgent(
|
||||
session: MissionInterviewSession,
|
||||
rootDir: string | undefined,
|
||||
historyForReplay: Array<{ question: PlanningQuestion; response: unknown }>,
|
||||
promptOverrides?: PromptOverrideMap,
|
||||
): Promise<void> {
|
||||
if (session.agent) {
|
||||
return;
|
||||
@@ -772,7 +782,7 @@ async function ensureMissionInterviewAgent(
|
||||
);
|
||||
}
|
||||
|
||||
session.agent = await createMissionInterviewAgent(session, rootDir);
|
||||
session.agent = await createMissionInterviewAgent(session, rootDir, promptOverrides);
|
||||
|
||||
if (historyForReplay.length === 0) {
|
||||
return;
|
||||
@@ -932,7 +942,8 @@ async function continueAgentConversation(session: MissionInterviewSession, messa
|
||||
export async function createMissionInterviewSession(
|
||||
ip: string,
|
||||
missionTitle: string,
|
||||
rootDir: string
|
||||
rootDir: string,
|
||||
promptOverrides?: PromptOverrideMap,
|
||||
): Promise<string> {
|
||||
if (!checkRateLimit(ip)) {
|
||||
const resetTime = getRateLimitResetTime(ip);
|
||||
@@ -960,7 +971,7 @@ export async function createMissionInterviewSession(
|
||||
persistMissionSession(session, "generating");
|
||||
|
||||
// Initialize AI agent in background
|
||||
initializeAgent(session, rootDir).catch((err) => {
|
||||
initializeAgent(session, rootDir, promptOverrides).catch((err) => {
|
||||
console.error(`[mission-interview] Failed to initialize agent for session ${sessionId}:`, err);
|
||||
persistMissionSession(session, "error", err.message || "Failed to initialize AI agent");
|
||||
missionInterviewStreamManager.broadcast(sessionId, {
|
||||
@@ -980,6 +991,7 @@ export async function submitMissionInterviewResponse(
|
||||
sessionId: string,
|
||||
responses: Record<string, unknown>,
|
||||
rootDir?: string,
|
||||
promptOverrides?: PromptOverrideMap,
|
||||
): Promise<MissionInterviewResponse> {
|
||||
const session = getMissionInterviewSession(sessionId);
|
||||
if (!session) {
|
||||
@@ -1001,7 +1013,7 @@ export async function submitMissionInterviewResponse(
|
||||
|
||||
if (!session.agent) {
|
||||
const replayHistory = session.history.slice(0, -1);
|
||||
await ensureMissionInterviewAgent(session, rootDir, replayHistory);
|
||||
await ensureMissionInterviewAgent(session, rootDir, replayHistory, promptOverrides);
|
||||
}
|
||||
|
||||
const message = formatResponseForAgent(session.currentQuestion, responses);
|
||||
@@ -1025,7 +1037,11 @@ export async function submitMissionInterviewResponse(
|
||||
};
|
||||
}
|
||||
|
||||
export async function retryMissionInterviewSession(sessionId: string, rootDir: string): Promise<void> {
|
||||
export async function retryMissionInterviewSession(
|
||||
sessionId: string,
|
||||
rootDir: string,
|
||||
promptOverrides?: PromptOverrideMap,
|
||||
): Promise<void> {
|
||||
const session = getMissionInterviewSession(sessionId);
|
||||
if (!session) {
|
||||
throw new SessionNotFoundError(`Mission interview session ${sessionId} not found or expired`);
|
||||
@@ -1049,7 +1065,7 @@ export async function retryMissionInterviewSession(sessionId: string, rootDir: s
|
||||
persistMissionSession(session, "generating");
|
||||
|
||||
if (session.history.length === 0) {
|
||||
await ensureMissionInterviewAgent(session, rootDir, []);
|
||||
await ensureMissionInterviewAgent(session, rootDir, [], promptOverrides);
|
||||
await continueAgentConversation(
|
||||
session,
|
||||
`I want to plan a mission: "${session.missionTitle}". Interview me to understand what I need, then produce a structured plan.`,
|
||||
@@ -1060,7 +1076,7 @@ export async function retryMissionInterviewSession(sessionId: string, rootDir: s
|
||||
const replayHistory = session.history.slice(0, -1);
|
||||
const lastEntry = session.history[session.history.length - 1];
|
||||
|
||||
await ensureMissionInterviewAgent(session, rootDir, replayHistory);
|
||||
await ensureMissionInterviewAgent(session, rootDir, replayHistory, promptOverrides);
|
||||
const replayMessage = formatResponseForAgent(
|
||||
lastEntry.question,
|
||||
coerceResponseRecord(lastEntry.question, lastEntry.response),
|
||||
|
||||
@@ -328,6 +328,14 @@ export function createMissionRouter(
|
||||
return scopedStore.getRootDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to resolve scoped store for the current request's project scope.
|
||||
*/
|
||||
async function getScopedStoreForRequest(req: Request) {
|
||||
const projectId = getProjectIdFromRequest(req);
|
||||
return projectId ? await getOrCreateProjectStore(projectId) : store;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/missions/interview/start
|
||||
* Start a mission interview session with AI agent streaming.
|
||||
@@ -349,14 +357,21 @@ export function createMissionRouter(
|
||||
|
||||
try {
|
||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||
const rootDir = await getRootDirForRequest(req);
|
||||
const scopedStore = await getScopedStoreForRequest(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const settings = await scopedStore.getSettings();
|
||||
|
||||
const {
|
||||
createMissionInterviewSession,
|
||||
RateLimitError,
|
||||
} = await import("./mission-interview.js");
|
||||
|
||||
const sessionId = await createMissionInterviewSession(ip, missionTitle.trim(), rootDir);
|
||||
const sessionId = await createMissionInterviewSession(
|
||||
ip,
|
||||
missionTitle.trim(),
|
||||
rootDir,
|
||||
settings.promptOverrides,
|
||||
);
|
||||
res.status(201).json({ sessionId });
|
||||
} catch (err: any) {
|
||||
if (err.name === "RateLimitError") {
|
||||
@@ -397,14 +412,22 @@ export function createMissionRouter(
|
||||
}
|
||||
|
||||
try {
|
||||
const scopedStore = await getScopedStoreForRequest(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const settings = await scopedStore.getSettings();
|
||||
|
||||
const {
|
||||
submitMissionInterviewResponse,
|
||||
SessionNotFoundError,
|
||||
InvalidSessionStateError,
|
||||
} = await import("./mission-interview.js");
|
||||
|
||||
const rootDir = await getRootDirForRequest(req);
|
||||
const result = await submitMissionInterviewResponse(sessionId, responses, rootDir);
|
||||
const result = await submitMissionInterviewResponse(
|
||||
sessionId,
|
||||
responses,
|
||||
rootDir,
|
||||
settings.promptOverrides,
|
||||
);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (err.name === "SessionNotFoundError") {
|
||||
@@ -444,14 +467,17 @@ export function createMissionRouter(
|
||||
}
|
||||
|
||||
try {
|
||||
const scopedStore = await getScopedStoreForRequest(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const settings = await scopedStore.getSettings();
|
||||
|
||||
const {
|
||||
retryMissionInterviewSession,
|
||||
SessionNotFoundError,
|
||||
InvalidSessionStateError,
|
||||
} = await import("./mission-interview.js");
|
||||
|
||||
const rootDir = await getRootDirForRequest(req);
|
||||
await retryMissionInterviewSession(sessionId, rootDir);
|
||||
await retryMissionInterviewSession(sessionId, rootDir, settings.promptOverrides);
|
||||
res.json({ success: true, sessionId });
|
||||
} catch (err: any) {
|
||||
if (err.name === "SessionNotFoundError") {
|
||||
|
||||
@@ -11554,3 +11554,119 @@ describe("Agent Reflection routes", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── AI Refine Text Route with Scoped Settings ────────────────────────────────
|
||||
|
||||
describe("POST /api/ai/refine-text with projectId scoping", () => {
|
||||
const projectId = "proj-refine-test";
|
||||
|
||||
let defaultStore: TaskStore;
|
||||
let scopedStore: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
defaultStore = createMockStore();
|
||||
scopedStore = createMockStore();
|
||||
|
||||
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 store when projectId is provided", async () => {
|
||||
(scopedStore.getSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
promptOverrides: {
|
||||
"ai-refine-system": "Custom AI refine prompt",
|
||||
},
|
||||
});
|
||||
|
||||
// The route will call refineText which requires AI engine
|
||||
// We verify that scoped store is correctly used by checking settings was called
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
`/api/ai/refine-text?projectId=${projectId}`,
|
||||
JSON.stringify({ text: "Task description", type: "clarify" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
// The route should call scoped store's getSettings (it may fail on AI call but settings was checked)
|
||||
expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId);
|
||||
expect(scopedStore.getSettings).toHaveBeenCalled();
|
||||
expect(scopedStore.getRootDir).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for missing text field", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
`/api/ai/refine-text?projectId=${projectId}`,
|
||||
JSON.stringify({ type: "clarify" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("text is required");
|
||||
});
|
||||
|
||||
it("returns 400 for missing type field", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
`/api/ai/refine-text?projectId=${projectId}`,
|
||||
JSON.stringify({ text: "Some text" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("type is required");
|
||||
});
|
||||
|
||||
it("returns 422 for invalid refinement type", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
`/api/ai/refine-text?projectId=${projectId}`,
|
||||
JSON.stringify({ text: "Some text", type: "invalid-type" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.body.error).toContain("type must be one of");
|
||||
});
|
||||
|
||||
it("returns 400 when text is empty", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/ai/refine-text",
|
||||
JSON.stringify({ text: "", type: "clarify" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("at least 1 character");
|
||||
});
|
||||
|
||||
it("returns 400 when text exceeds 2000 characters", async () => {
|
||||
const longText = "a".repeat(2001);
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/ai/refine-text",
|
||||
JSON.stringify({ text: longText, type: "clarify" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("not exceed 2000 characters");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7690,7 +7690,11 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
try {
|
||||
const { text, type } = req.body;
|
||||
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
||||
const rootDir = store.getRootDir();
|
||||
|
||||
// Get scoped store and settings for prompt overrides
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
const settings = await scopedStore.getSettings();
|
||||
|
||||
const {
|
||||
validateRefineRequest,
|
||||
@@ -7723,8 +7727,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Process refinement
|
||||
const refined = await refineText(validated.text, validated.type, rootDir);
|
||||
// Process refinement with prompt overrides
|
||||
const refined = await refineText(
|
||||
validated.text,
|
||||
validated.type,
|
||||
rootDir,
|
||||
settings.promptOverrides,
|
||||
);
|
||||
res.json({ refined });
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
|
||||
Reference in New Issue
Block a user