feat(FN-1446): wire planning + subtask prompt override resolution with regression tests
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user