feat(FN-4041): harden empty rawResponse handling in memory-insights
Hardened empty `rawResponse` handling in the memory insights module by adding defensive checks to prevent errors when the response is empty, accompanied by tests to cover those edge cases (FN-4041 Step 1). Fusion-Task-Id: FN-4041
This commit is contained in:
@@ -838,6 +838,19 @@ describe("memory-insights run processing", () => {
|
|||||||
expect(result.summary).toContain("AI timeout");
|
expect(result.summary).toContain("AI timeout");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should treat undefined successful response as no output", async () => {
|
||||||
|
const result = await processInsightExtractionRun(tempDir, {
|
||||||
|
rawResponse: undefined,
|
||||||
|
stepSuccess: true,
|
||||||
|
runAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.insights).toHaveLength(0);
|
||||||
|
expect(result.summary).toBe("Step did not produce output");
|
||||||
|
expect(result.newInsightCount).toBe(0);
|
||||||
|
expect(result.duplicateCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("should preserve existing insights on failure", async () => {
|
it("should preserve existing insights on failure", async () => {
|
||||||
// Create existing insights
|
// Create existing insights
|
||||||
const existingInsights = `# Memory Insights
|
const existingInsights = `# Memory Insights
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ interface MemoryAuditState {
|
|||||||
/** Input for processing an insight extraction run. */
|
/** Input for processing an insight extraction run. */
|
||||||
export interface ProcessRunInput {
|
export interface ProcessRunInput {
|
||||||
/** Raw AI response text from the insight extraction step. */
|
/** Raw AI response text from the insight extraction step. */
|
||||||
rawResponse: string;
|
rawResponse?: string;
|
||||||
/** Whether the AI step itself succeeded. */
|
/** Whether the AI step itself succeeded. */
|
||||||
stepSuccess: boolean;
|
stepSuccess: boolean;
|
||||||
/** Timestamp of the run. */
|
/** Timestamp of the run. */
|
||||||
@@ -1118,7 +1118,7 @@ export async function processInsightExtractionRun(
|
|||||||
let parseError: string | undefined;
|
let parseError: string | undefined;
|
||||||
|
|
||||||
// Try to parse the AI response
|
// Try to parse the AI response
|
||||||
if (stepSuccess && rawResponse.trim()) {
|
if (stepSuccess && rawResponse?.trim()) {
|
||||||
try {
|
try {
|
||||||
parsedResult = parseInsightExtractionResponse(rawResponse);
|
parsedResult = parseInsightExtractionResponse(rawResponse);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1623,6 +1623,53 @@ describe("approved triage recovery", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves imported GitHub issue titles during planning recovery", async () => {
|
||||||
|
await writeFile(
|
||||||
|
join(rootDir, ".fusion", "tasks", "FN-001", "PROMPT.md"),
|
||||||
|
"# Task: FN-001 - Different AI-generated planning title\n\n**Size:** M\n\n## Review Level: 2\n\nRecovered specification",
|
||||||
|
);
|
||||||
|
|
||||||
|
const store = createMockStore({
|
||||||
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
|
maxConcurrent: 2,
|
||||||
|
maxWorktrees: 4,
|
||||||
|
pollIntervalMs: 10000,
|
||||||
|
groupOverlappingFiles: false,
|
||||||
|
autoMerge: true,
|
||||||
|
requirePlanApproval: false,
|
||||||
|
} as Settings),
|
||||||
|
});
|
||||||
|
|
||||||
|
const processor = new TriageProcessor(store, rootDir);
|
||||||
|
const recovered = await processor.recoverApprovedTask({
|
||||||
|
id: "FN-001",
|
||||||
|
description: "Imported from GitHub",
|
||||||
|
column: "triage",
|
||||||
|
status: "planning",
|
||||||
|
title: '"Cannot read properties of undefined (reading \'trim\')" when extracting insights',
|
||||||
|
sourceType: "github_import",
|
||||||
|
sourceIssue: {
|
||||||
|
provider: "github",
|
||||||
|
repository: "Runfusion/Fusion",
|
||||||
|
externalIssueId: "70",
|
||||||
|
issueNumber: 70,
|
||||||
|
url: "https://github.com/Runfusion/Fusion/issues/70",
|
||||||
|
},
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [{ timestamp: "2026-01-01T00:00:00.000Z", action: "Spec review: APPROVE" }],
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:02:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recovered).toBe(true);
|
||||||
|
expect(store.updateTask).not.toHaveBeenCalledWith(
|
||||||
|
"FN-001",
|
||||||
|
expect.objectContaining({ title: "Different AI-generated planning title" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("clears status and error before moving approved tasks to todo", async () => {
|
it("clears status and error before moving approved tasks to todo", async () => {
|
||||||
const store = createMockStore({
|
const store = createMockStore({
|
||||||
getSettings: vi.fn().mockResolvedValue({
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
|
|||||||
@@ -2064,12 +2064,13 @@ export class TriageProcessor {
|
|||||||
// ordering as defense in depth so a future change to the guard can't
|
// ordering as defense in depth so a future change to the guard can't
|
||||||
// resurrect the regression.
|
// resurrect the regression.
|
||||||
const promptDeclaredTitle = extractPromptDeclaredTitle(written, task.id);
|
const promptDeclaredTitle = extractPromptDeclaredTitle(written, task.id);
|
||||||
|
const shouldApplyPromptDeclaredTitle = shouldReplaceTaskTitleFromPrompt(task, promptDeclaredTitle);
|
||||||
|
|
||||||
await this.store.updateTask(task.id, taskUpdates);
|
await this.store.updateTask(task.id, taskUpdates);
|
||||||
|
|
||||||
if (settings.requirePlanApproval) {
|
if (settings.requirePlanApproval) {
|
||||||
const approvalUpdates: Record<string, unknown> = { status: "awaiting-approval" };
|
const approvalUpdates: Record<string, unknown> = { status: "awaiting-approval" };
|
||||||
if (promptDeclaredTitle) {
|
if (shouldApplyPromptDeclaredTitle && promptDeclaredTitle) {
|
||||||
approvalUpdates.title = promptDeclaredTitle;
|
approvalUpdates.title = promptDeclaredTitle;
|
||||||
}
|
}
|
||||||
await this.store.updateTask(task.id, approvalUpdates);
|
await this.store.updateTask(task.id, approvalUpdates);
|
||||||
@@ -2083,7 +2084,7 @@ export class TriageProcessor {
|
|||||||
|
|
||||||
await this.store.moveTask(task.id, "todo");
|
await this.store.moveTask(task.id, "todo");
|
||||||
|
|
||||||
if (promptDeclaredTitle) {
|
if (shouldApplyPromptDeclaredTitle && promptDeclaredTitle) {
|
||||||
await this.store.updateTask(task.id, { title: promptDeclaredTitle });
|
await this.store.updateTask(task.id, { title: promptDeclaredTitle });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2112,13 +2113,32 @@ function extractPromptDeclaredTitle(prompt: string, taskId: string): string | nu
|
|||||||
if (!title) return null;
|
if (!title) return null;
|
||||||
|
|
||||||
// Conservative guard: do not overwrite metadata with confirmation prose.
|
// Conservative guard: do not overwrite metadata with confirmation prose.
|
||||||
if (/^created\s+(?:task\s+)?(?:fn-\d+\b|\*\*\s*fn-\d+\s*\*\*)/i.test(title)) {
|
if (isMalformedTaskTitle(title)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return title;
|
return title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMalformedTaskTitle(title: string): boolean {
|
||||||
|
return /^created\s+(?:task\s+)?(?:fn-\d+\b|\*\*\s*fn-\d+\s*\*\*)/i.test(title.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldReplaceTaskTitleFromPrompt(task: Task, promptDeclaredTitle: string | null): boolean {
|
||||||
|
if (!promptDeclaredTitle) return false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
task.sourceType === "github_import" &&
|
||||||
|
task.sourceIssue?.provider === "github" &&
|
||||||
|
task.title?.trim() &&
|
||||||
|
!isMalformedTaskTitle(task.title)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function hasLatestSpecReviewApproval(task: Task): boolean {
|
function hasLatestSpecReviewApproval(task: Task): boolean {
|
||||||
for (let i = task.log.length - 1; i >= 0; i--) {
|
for (let i = task.log.length - 1; i >= 0; i--) {
|
||||||
const action = task.log[i]?.action ?? "";
|
const action = task.log[i]?.action ?? "";
|
||||||
|
|||||||
Reference in New Issue
Block a user