feat(FN-1446): wire planning + subtask prompt override resolution with regression tests

This commit is contained in:
gsxdsm
2026-04-10 19:31:27 -07:00
parent 93bfdc1e76
commit b8a0b6ccbf
8 changed files with 390 additions and 29 deletions

View File

@@ -95,6 +95,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const justResetRef = useRef(false);
const previousProjectIdRef = useRef(projectId);
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const pendingImagesRef = useRef<PendingImage[]>([]);
@@ -104,6 +105,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const [depSearch, setDepSearch] = useState("");
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [agents, setAgents] = useState<Agent[]>([]);
const [agentsProjectId, setAgentsProjectId] = useState<string | undefined>(undefined);
const [showAgentPicker, setShowAgentPicker] = useState(false);
const [agentsLoading, setAgentsLoading] = useState(false);
const [isModelMenuOpen, setIsModelMenuOpen] = useState(false);
@@ -122,7 +124,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const depTriggerRef = useRef<HTMLButtonElement>(null);
const depDropdownPortalRef = useRef<HTMLDivElement>(null);
const [depDropdownPosition, setDepDropdownPosition] = useState<{ top: number; left: number; width: number; maxHeight?: number } | null>(null);
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
const [portalRoot] = useState<HTMLElement | null>(() =>
typeof document !== "undefined" ? document.body : null,
);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>(availableModels ?? []);
@@ -232,8 +236,14 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
// Clear agents cache when projectId changes to prevent stale agents from leaking across projects
useEffect(() => {
if (previousProjectIdRef.current === projectId) {
return;
}
previousProjectIdRef.current = projectId;
setAgents([]);
setAgentsProjectId(undefined);
setSelectedAgentId(null);
setShowAgentPicker(false);
}, [projectId]);
// Clean up legacy disclosure persistence key from previous versions
@@ -243,11 +253,6 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
}
}, []);
// Set portal root for model menu rendering
useEffect(() => {
setPortalRoot(document.body);
}, []);
useEffect(() => {
pendingImagesRef.current = pendingImages;
}, [pendingImages]);
@@ -961,7 +966,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
}, [availableModels, parentFavoriteProviders, parentFavoriteModels]);
const loadAgents = useCallback(async () => {
if (agents.length > 0) {
if (agents.length > 0 && agentsProjectId === projectId) {
setShowAgentPicker(true);
return;
}
@@ -970,6 +975,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
try {
const result = await fetchAgents(undefined, projectId);
setAgents(result);
setAgentsProjectId(projectId);
setShowAgentPicker(true);
} catch (err: any) {
addToast(err?.message ? `Failed to load agents: ${err.message}` : "Failed to load agents", "error");
@@ -977,7 +983,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
} finally {
setAgentsLoading(false);
}
}, [agents.length, projectId, addToast]);
}, [agents.length, agentsProjectId, projectId, addToast]);
const selectedAgent = selectedAgentId ? agents.find((agent) => agent.id === selectedAgentId) : undefined;
const selectedAgentLabel = selectedAgent?.name ?? selectedAgentId;

View File

@@ -419,6 +419,81 @@ describe("planning module", () => {
expect(callArg?.defaultProvider).toBeUndefined();
expect(callArg?.defaultModelId).toBeUndefined();
});
it("uses custom prompt from promptOverrides when provided", async () => {
const createKbAgentSpy = vi.fn(async () => createMockAgent(STANDARD_QUESTION_RESPONSES));
__setCreateKbAgent(createKbAgentSpy as any);
const customPrompt = "Custom planning prompt with specific guidelines...";
const promptOverrides = { "planning-system": customPrompt };
const sessionId = await createSessionWithAgent(
getUniqueIp(),
"Build auth system",
TEST_ROOT_DIR,
undefined,
undefined,
promptOverrides,
);
expect(sessionId).toBeDefined();
await vi.waitFor(() => {
expect(createKbAgentSpy).toHaveBeenCalledTimes(1);
}, { timeout: 10000 });
const callArg = createKbAgentSpy.mock.calls[0]?.[0] as Record<string, unknown>;
expect(callArg?.systemPrompt).toBe(customPrompt);
});
it("falls back to default prompt when promptOverrides is undefined", async () => {
const createKbAgentSpy = vi.fn(async () => createMockAgent(STANDARD_QUESTION_RESPONSES));
__setCreateKbAgent(createKbAgentSpy as any);
const sessionId = await createSessionWithAgent(
getUniqueIp(),
"Build auth system",
TEST_ROOT_DIR,
undefined,
undefined,
undefined,
);
expect(sessionId).toBeDefined();
await vi.waitFor(() => {
expect(createKbAgentSpy).toHaveBeenCalledTimes(1);
}, { timeout: 10000 });
const callArg = createKbAgentSpy.mock.calls[0]?.[0] as Record<string, unknown>;
expect(callArg?.systemPrompt).toContain("planning assistant");
});
it("falls back to default prompt when promptOverrides does not contain planning key", async () => {
const createKbAgentSpy = vi.fn(async () => createMockAgent(STANDARD_QUESTION_RESPONSES));
__setCreateKbAgent(createKbAgentSpy as any);
// Provide an override for a different key
const promptOverrides = { "triage-welcome": "Some other prompt" };
const sessionId = await createSessionWithAgent(
getUniqueIp(),
"Build auth system",
TEST_ROOT_DIR,
undefined,
undefined,
promptOverrides,
);
expect(sessionId).toBeDefined();
await vi.waitFor(() => {
expect(createKbAgentSpy).toHaveBeenCalledTimes(1);
}, { timeout: 10000 });
const callArg = createKbAgentSpy.mock.calls[0]?.[0] as Record<string, unknown>;
expect(callArg?.systemPrompt).toContain("planning assistant");
});
});
describe("submitResponse", () => {
@@ -716,6 +791,95 @@ describe("planning module", () => {
await expect(retrySession(row.id, TEST_ROOT_DIR)).rejects.toThrow(InvalidSessionStateError);
});
it("uses custom prompt from promptOverrides on retry", async () => {
const store = new MockAiSessionStore();
const row = buildPlanningRow({
id: "planning-retry-with-override",
status: "error",
error: "Transient failure",
conversationHistory: JSON.stringify([
{
question: { id: "q-1", type: "text", question: "What to build?", description: "scope" },
response: { "q-1": "Auth" },
},
]),
currentQuestion: JSON.stringify({
id: "q-2",
type: "text",
question: "Any constraints?",
description: "details",
}),
});
store.rows.set(row.id, row);
setAiSessionStore(store as any);
const customPrompt = "Custom retry prompt...";
const promptOverrides = { "planning-system": customPrompt };
const resumedAgent = createMockAgent([
JSON.stringify({
type: "question",
data: {
id: "q-retry",
type: "text",
question: "Deadline?",
description: "timing",
},
}),
]);
const createKbAgentSpy = vi.fn(async () => resumedAgent);
__setCreateKbAgent(createKbAgentSpy as any);
await retrySession(row.id, TEST_ROOT_DIR, promptOverrides);
expect(createKbAgentSpy).toHaveBeenCalledTimes(1);
const callArg = createKbAgentSpy.mock.calls[0]?.[0] as Record<string, unknown>;
expect(callArg?.systemPrompt).toBe(customPrompt);
});
it("falls back to default prompt on retry when promptOverrides is undefined", async () => {
const store = new MockAiSessionStore();
const row = buildPlanningRow({
id: "planning-retry-no-override",
status: "error",
error: "Transient failure",
conversationHistory: JSON.stringify([
{
question: { id: "q-1", type: "text", question: "What to build?", description: "scope" },
response: { "q-1": "Auth" },
},
]),
currentQuestion: JSON.stringify({
id: "q-2",
type: "text",
question: "Any constraints?",
description: "details",
}),
});
store.rows.set(row.id, row);
setAiSessionStore(store as any);
const resumedAgent = createMockAgent([
JSON.stringify({
type: "question",
data: {
id: "q-retry",
type: "text",
question: "Deadline?",
description: "timing",
},
}),
]);
const createKbAgentSpy = vi.fn(async () => resumedAgent);
__setCreateKbAgent(createKbAgentSpy as any);
await retrySession(row.id, TEST_ROOT_DIR);
expect(createKbAgentSpy).toHaveBeenCalledTimes(1);
const callArg = createKbAgentSpy.mock.calls[0]?.[0] as Record<string, unknown>;
expect(callArg?.systemPrompt).toContain("planning assistant");
});
});
describe("cancelSession", () => {

View File

@@ -1218,6 +1218,7 @@ export async function submitResponse(
sessionId: string,
responses: Record<string, unknown>,
rootDir?: string,
promptOverrides?: PromptOverrideMap,
): Promise<PlanningResponse> {
const session = getSession(sessionId);
if (!session) {
@@ -1239,7 +1240,7 @@ export async function submitResponse(
if (!session.agent) {
const replayHistory = session.history.slice(0, -1);
await ensureSessionAgent(session, rootDir, replayHistory);
await ensureSessionAgent(session, rootDir, replayHistory, promptOverrides);
}
const message = formatResponseForAgent(session.currentQuestion, responses);
@@ -1257,7 +1258,11 @@ export async function submitResponse(
throw new InvalidSessionStateError("AI agent did not return a question or summary");
}
export async function retrySession(sessionId: string, rootDir: string): Promise<void> {
export async function retrySession(
sessionId: string,
rootDir: string,
promptOverrides?: PromptOverrideMap,
): Promise<void> {
const session = getSession(sessionId);
if (!session) {
throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
@@ -1281,7 +1286,7 @@ export async function retrySession(sessionId: string, rootDir: string): Promise<
persistSession(session, "generating");
if (session.history.length === 0) {
await ensureSessionAgent(session, rootDir, []);
await ensureSessionAgent(session, rootDir, [], promptOverrides);
await continueAgentConversation(session, session.initialPlan);
return;
}
@@ -1289,7 +1294,7 @@ export async function retrySession(sessionId: string, rootDir: string): Promise<
const replayHistory = session.history.slice(0, -1);
const lastEntry = session.history[session.history.length - 1];
await ensureSessionAgent(session, rootDir, replayHistory);
await ensureSessionAgent(session, rootDir, replayHistory, promptOverrides);
const replayMessage = formatResponseForAgent(
lastEntry.question,
coerceResponseRecord(lastEntry.question, lastEntry.response),

View File

@@ -841,7 +841,7 @@ describe("POST /subtasks/*", () => {
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, sessionId: "session-123" });
expect(retrySpy).toHaveBeenCalledWith("session-123", "/fake/root");
expect(retrySpy).toHaveBeenCalledWith("session-123", "/fake/root", undefined);
});
it("returns 404 when subtask retry session does not exist", async () => {
@@ -7366,7 +7366,7 @@ describe("Git Management endpoints", () => {
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, sessionId: "session-123" });
expect(retrySpy).toHaveBeenCalledWith("session-123", expect.any(String));
expect(retrySpy).toHaveBeenCalledWith("session-123", expect.any(String), undefined);
});
it("returns 404 when planning retry session is missing", async () => {

View File

@@ -6324,8 +6324,14 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
const scopedStore = await getScopedStore(req);
const settings = await scopedStore.getSettings();
const { createSubtaskSession } = await import("./subtask-breakdown.js");
const session = await createSubtaskSession(description, scopedStore, scopedStore.getRootDir());
const session = await createSubtaskSession(
description,
scopedStore,
scopedStore.getRootDir(),
settings.promptOverrides,
);
res.status(201).json({ sessionId: session.sessionId });
} catch (err: any) {
if (err instanceof ApiError) {
@@ -6593,8 +6599,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
const scopedStore = await getScopedStore(req);
const settings = await scopedStore.getSettings();
const { retrySubtaskSession } = await import("./subtask-breakdown.js");
await retrySubtaskSession(sessionId, scopedStore.getRootDir());
await retrySubtaskSession(sessionId, scopedStore.getRootDir(), settings.promptOverrides);
res.json({ success: true, sessionId });
} catch (err: any) {
if (err instanceof ApiError) {
@@ -6629,11 +6636,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
const scopedStore = await getScopedStore(req);
const settings = await scopedStore.getSettings();
const ip = req.ip || req.socket.remoteAddress || "unknown";
const rootDir = scopedStore.getRootDir();
const { createSession, RateLimitError } = await import("./planning.js");
const result = await createSession(ip, initialPlan, scopedStore, rootDir);
const result = await createSession(
ip,
initialPlan,
scopedStore,
rootDir,
settings.promptOverrides,
);
res.status(201).json(result);
} catch (err: any) {
if (err instanceof ApiError) {
@@ -6677,6 +6691,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
const scopedStore = await getScopedStore(req);
const settings = await scopedStore.getSettings();
const ip = req.ip || req.socket.remoteAddress || "unknown";
const rootDir = scopedStore.getRootDir();
@@ -6687,6 +6702,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
rootDir,
planningModelProvider,
planningModelId,
settings.promptOverrides,
);
res.status(201).json({ sessionId });
} catch (err: any) {
@@ -6729,8 +6745,15 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return;
}
const scopedStore = await getScopedStore(req);
const settings = await scopedStore.getSettings();
const { submitResponse, SessionNotFoundError, InvalidSessionStateError } = await import("./planning.js");
const result = await submitResponse(sessionId, responses, store.getRootDir());
const result = await submitResponse(
sessionId,
responses,
scopedStore.getRootDir(),
settings.promptOverrides,
);
res.json(result);
} catch (err: any) {
if (err instanceof ApiError) {
@@ -6766,8 +6789,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
const scopedStore = await getScopedStore(req);
const settings = await scopedStore.getSettings();
const { retrySession } = await import("./planning.js");
await retrySession(sessionId, scopedStore.getRootDir());
await retrySession(sessionId, scopedStore.getRootDir(), settings.promptOverrides);
res.json({ success: true, sessionId });
} catch (err: any) {
if (err instanceof ApiError) {

View File

@@ -460,6 +460,84 @@ describe("subtask session lifecycle", () => {
expect(session).not.toHaveProperty("thinkingOutput");
});
it("createSubtaskSession uses custom prompt from promptOverrides", async () => {
const description = "Build test coverage for new API";
const customPrompt = "Custom subtask prompt...";
const promptOverrides = { "subtask-breakdown-system": customPrompt };
const createKbAgentSpy = vi.fn().mockImplementation(async () => ({
session: {
state: { messages: [] },
prompt: vi.fn(),
dispose: vi.fn(),
},
}));
mockCreateKbAgent.mockImplementation(createKbAgentSpy);
const created = await createSubtaskSession(description, undefined, "/tmp/project", promptOverrides);
// Wait for the session generation to complete
await vi.waitFor(() => {
const session = getSubtaskSession(created.sessionId);
return session?.status === "complete";
}, { timeout: 5000 });
expect(createKbAgentSpy).toHaveBeenCalledTimes(1);
const callArg = createKbAgentSpy.mock.calls[0]?.[0] as Record<string, unknown>;
expect(callArg?.systemPrompt).toBe(customPrompt);
});
it("createSubtaskSession falls back to default prompt when promptOverrides is undefined", async () => {
const description = "Build test coverage for new API";
const createKbAgentSpy = vi.fn().mockImplementation(async () => ({
session: {
state: { messages: [] },
prompt: vi.fn(),
dispose: vi.fn(),
},
}));
mockCreateKbAgent.mockImplementation(createKbAgentSpy);
const created = await createSubtaskSession(description, undefined, "/tmp/project");
// Wait for the session generation to complete
await vi.waitFor(() => {
const session = getSubtaskSession(created.sessionId);
return session?.status === "complete";
}, { timeout: 5000 });
expect(createKbAgentSpy).toHaveBeenCalledTimes(1);
const callArg = createKbAgentSpy.mock.calls[0]?.[0] as Record<string, unknown>;
expect(callArg?.systemPrompt).toContain("task decomposition assistant");
});
it("createSubtaskSession falls back to default prompt when promptOverrides does not contain subtask key", async () => {
const description = "Build test coverage for new API";
const promptOverrides = { "planning-system": "Some other prompt" };
const createKbAgentSpy = vi.fn().mockImplementation(async () => ({
session: {
state: { messages: [] },
prompt: vi.fn(),
dispose: vi.fn(),
},
}));
mockCreateKbAgent.mockImplementation(createKbAgentSpy);
const created = await createSubtaskSession(description, undefined, "/tmp/project", promptOverrides);
// Wait for the session generation to complete
await vi.waitFor(() => {
const session = getSubtaskSession(created.sessionId);
return session?.status === "complete";
}, { timeout: 5000 });
expect(createKbAgentSpy).toHaveBeenCalledTimes(1);
const callArg = createKbAgentSpy.mock.calls[0]?.[0] as Record<string, unknown>;
expect(callArg?.systemPrompt).toContain("task decomposition assistant");
});
it("retrySubtaskSession retries errored sessions restored from SQLite", async () => {
const store = new MockAiSessionStore();
const row = buildSubtaskRow({
@@ -492,6 +570,67 @@ describe("subtask session lifecycle", () => {
);
});
it("retrySubtaskSession uses custom prompt from promptOverrides", async () => {
const store = new MockAiSessionStore();
const row = buildSubtaskRow({
id: "subtask-retry-with-override",
status: "error",
error: "Transient failure",
result: null,
});
store.rows.set(row.id, row);
setAiSessionStore(store as any);
const customPrompt = "Custom subtask breakdown prompt...";
const promptOverrides = { "subtask-breakdown-system": customPrompt };
mockCreateKbAgent.mockReset();
const createKbAgentSpy = vi.fn().mockImplementation(async () => ({
session: {
state: { messages: [] },
prompt: vi.fn(),
dispose: vi.fn(),
},
}));
mockCreateKbAgent.mockImplementation(createKbAgentSpy);
await retrySubtaskSession(row.id, "/tmp/project", promptOverrides);
expect(createKbAgentSpy).toHaveBeenCalledTimes(1);
const callArg = createKbAgentSpy.mock.calls[0]?.[0] as Record<string, unknown>;
expect(callArg?.systemPrompt).toBe(customPrompt);
});
it("retrySubtaskSession falls back to default prompt when promptOverrides is undefined", async () => {
const store = new MockAiSessionStore();
const row = buildSubtaskRow({
id: "subtask-retry-no-override",
status: "error",
error: "Transient failure",
result: null,
});
store.rows.set(row.id, row);
setAiSessionStore(store as any);
mockCreateKbAgent.mockReset();
const createKbAgentSpy = vi.fn().mockImplementation(async () => ({
session: {
state: { messages: [] },
prompt: vi.fn(),
dispose: vi.fn(),
},
}));
mockCreateKbAgent.mockImplementation(createKbAgentSpy);
await retrySubtaskSession(row.id, "/tmp/project");
expect(createKbAgentSpy).toHaveBeenCalledTimes(1);
const callArg = createKbAgentSpy.mock.calls[0]?.[0] as Record<string, unknown>;
expect(callArg?.systemPrompt).toContain("task decomposition assistant");
});
it("cancelSubtaskSession throws SessionNotFoundError for unknown session", async () => {
await expect(cancelSubtaskSession("missing-session")).rejects.toMatchObject({
name: "SessionNotFoundError",

View File

@@ -1,4 +1,5 @@
import type { TaskStore } from "@fusion/core";
import { resolvePrompt, type PromptOverrideMap } from "@fusion/core";
import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import type { AiSessionStore, AiSessionRow } from "./ai-session-store.js";
@@ -326,7 +327,12 @@ export class SubtaskStreamManager extends EventEmitter {
export const subtaskStreamManager = new SubtaskStreamManager();
export async function createSubtaskSession(initialDescription: string, _store?: TaskStore, rootDir?: string): Promise<SubtaskSession> {
export async function createSubtaskSession(
initialDescription: string,
_store?: TaskStore,
rootDir?: string,
promptOverrides?: PromptOverrideMap,
): Promise<SubtaskSession> {
const sessionId = randomUUID();
const session = {
sessionId,
@@ -341,7 +347,7 @@ export async function createSubtaskSession(initialDescription: string, _store?:
persistSubtaskSession(session, "generating");
const cwd = rootDir ?? process.cwd();
void startSubtaskGeneration(sessionId, cwd);
void startSubtaskGeneration(sessionId, cwd, promptOverrides);
return {
sessionId,
@@ -352,9 +358,13 @@ export async function createSubtaskSession(initialDescription: string, _store?:
};
}
async function startSubtaskGeneration(sessionId: string, cwd: string): Promise<void> {
async function startSubtaskGeneration(
sessionId: string,
cwd: string,
promptOverrides?: PromptOverrideMap,
): Promise<void> {
try {
await generateSubtasks(sessionId, cwd);
await generateSubtasks(sessionId, cwd, promptOverrides);
} catch (err) {
const existing = sessions.get(sessionId);
if (!existing) return;
@@ -366,16 +376,23 @@ async function startSubtaskGeneration(sessionId: string, cwd: string): Promise<v
}
}
async function generateSubtasks(sessionId: string, cwd: string): Promise<void> {
async function generateSubtasks(
sessionId: string,
cwd: string,
promptOverrides?: PromptOverrideMap,
): Promise<void> {
const session = sessions.get(sessionId);
if (!session) throw new SessionNotFoundError(`Subtask session ${sessionId} not found`);
await engineReady;
// Resolve the effective system prompt (override or default)
const systemPrompt = resolvePrompt("subtask-breakdown-system", promptOverrides) || SUBTASK_BREAKDOWN_PROMPT;
if (createKbAgent) {
const agent = await createKbAgent({
cwd,
systemPrompt: SUBTASK_BREAKDOWN_PROMPT,
systemPrompt,
tools: "readonly",
onThinking: (delta: string) => {
const current = sessions.get(sessionId);
@@ -483,7 +500,11 @@ function disposeSubtaskAgentForRetry(session: SubtaskInternalSession): void {
session.agent = undefined;
}
export async function retrySubtaskSession(sessionId: string, rootDir: string): Promise<void> {
export async function retrySubtaskSession(
sessionId: string,
rootDir: string,
promptOverrides?: PromptOverrideMap,
): Promise<void> {
const visibleSession = getSubtaskSession(sessionId);
if (!visibleSession) {
throw new SessionNotFoundError(`Subtask session ${sessionId} not found or expired`);
@@ -513,7 +534,7 @@ export async function retrySubtaskSession(sessionId: string, rootDir: string): P
session.updatedAt = new Date();
persistSubtaskSession(session, "generating");
await startSubtaskGeneration(sessionId, rootDir);
await startSubtaskGeneration(sessionId, rootDir, promptOverrides);
}
export function getSubtaskSession(sessionId: string): SubtaskSession | undefined {