fix(confidence-engine): preserve follow-up context while processing

This commit is contained in:
2026-08-30 10:43:57 +01:00
parent ae1201bb27
commit bf7629691f
3 changed files with 462 additions and 115 deletions
@@ -2250,4 +2250,253 @@ describe("v0.49 RENDERED — in-place follow-up context ownership", () => {
expect(currentQuestionLabels).toHaveLength(0);
});
});
// ── v0.51 CASE A: processing preserves workspace context ─────────────
describe("processing state preserves completed context and active follow-up", () => {
it("completed narrative remains visible during processing (hasCompletedContext stays true via contributions fallback)", async () => {
const prevQ = "Which specific step of the onboarding funnel has the highest abandonment rate?";
const prevA = "Step 3 — account verification / phone confirmation.";
const followUpQ = "What drives the Step 3 abandonment rate?";
const contrib = makeContrib(prevQ, prevA, 1);
// Simulate post-submit processing: result still exists (hasCompletedContext stays true),
// but processingStep === "active" used to collapse context.
renderFQB({
focused: {
question: followUpQ,
answer: null,
status: "formulated",
result: {
observations: [contrib.observations[0]],
uncertainties: ["Is this causal?"],
possibleFollowUpQuestions: [followUpQ, "How does it compare to competitors?"],
assumptions: [],
relationships: [],
},
error: null,
},
focusedContributions: [contrib],
processingStep: "active", // ← THIS IS THE DEFECT: hasCompletedContext becomes false
deconstructMsg: "Working through your response…", // matches DECONSTRUCT_MESSAGES[0]
});
// Completed context must remain during processing
expect(screen.getByText("Previously answered")).toBeInTheDocument();
expect(screen.getByText(prevQ)).toBeInTheDocument();
expect(screen.getByText("Your response")).toBeInTheDocument();
expect(screen.getByText(prevA)).toBeInTheDocument();
// Active follow-up question remains visible under Questions this raises
expect(screen.getByText(followUpQ)).toBeInTheDocument();
expect(screen.getByText("Questions this raises")).toBeInTheDocument();
// Processing message present (spinner + text)
expect(screen.getByText(/Working through your response/i)).toBeInTheDocument();
});
it("active follow-up textarea present but disabled during processing, spinner shown", async () => {
const contrib = makeContrib("Q3", "A3", 1);
renderFQB({
focused: {
question: "Q4",
answer: null,
status: "formulated",
result: { observations: [], uncertainties: [], possibleFollowUpQuestions: ["Q4"] },
error: null,
},
focusedContributions: [contrib],
processingStep: "active",
deconstructMsg: "Working through your response…",
});
// In-place textarea visible (not hidden) but disabled during processing
const followUpTextarea = screen.queryAllByTestId("follow-up-textarea");
expect(followUpTextarea).toHaveLength(1);
expect(followUpTextarea[0].disabled).toBe(true);
// Submit button also disabled
const submitBtn = screen.getByRole("button", { name: /submit/i });
expect(submitBtn.disabled).toBe(true);
// Processing message visible
expect(screen.getByText(/Working through your response/i)).toBeInTheDocument();
});
it("previous contributions data available during processing for derived sections", async () => {
const contrib = makeContrib("Q3", "A3", 1);
renderFQB({
focused: {
question: "Q4",
answer: null,
status: "formulated",
result: { observations: [], uncertainties: [], possibleFollowUpQuestions: ["Q4"] },
error: null,
},
focusedContributions: [contrib],
processingStep: "active",
});
// Derived sections (from priorContribs fallback) remain visible — "What this tells us" etc.
expect(screen.getByText("What this tells us")).toBeInTheDocument();
});
it("no top-level QUESTION Q4 screen during processing", async () => {
const contrib = makeContrib("Q3", "A3", 1);
renderFQB({
focused: {
question: "Q4",
answer: null,
status: "formulated",
result: { observations: [], uncertainties: [], possibleFollowUpQuestions: ["Q4"] },
error: null,
},
focusedContributions: [contrib],
processingStep: "active",
});
// Should NOT show a top-level "Question" heading (the active block only)
const questionHeadings = screen.queryAllByText(/^Question$/);
expect(questionHeadings).toHaveLength(0);
});
});
// ── v0.51 CASE B: error preserves workspace context ────────────────
describe("error state preserves completed context and active follow-up", () => {
it("completed narrative remains visible after deconstruction failure", async () => {
const prevQ = "Which specific step of the onboarding funnel has the highest abandonment rate?";
const prevA = "Step 3 — account verification / phone confirmation.";
const followUpQ = "What drives the Step 3 abandonment rate?";
// Prior contribution includes possibleFollowUpQuestions (real deconstruction always returns them)
const contrib = { ...makeContrib(prevQ, prevA, 1), possibleFollowUpQuestions: [followUpQ] };
// Simulate error state: result cleared to null by handleDeconstructSubmit catch block
renderFQB({
focused: {
question: followUpQ,
answer: null,
status: "formulated",
result: null, // ← NULLED BY ERROR HANDLER (but priorContribs still has the data)
error: "Deconstruction failed",
},
focusedContributions: [contrib],
processingStep: "idle",
});
// Completed context from contributions must survive the error
expect(screen.getByText("Previously answered")).toBeInTheDocument();
expect(screen.getByText(prevQ)).toBeInTheDocument();
expect(screen.getByText("Your response")).toBeInTheDocument();
expect(screen.getByText(prevA)).toBeInTheDocument();
// Active follow-up remains under Questions This Raises
expect(screen.getByText(followUpQ)).toBeInTheDocument();
expect(screen.getByText("Questions this raises")).toBeInTheDocument();
});
it("failed submitted response visible with YOUR RESPONSE label", async () => {
const prevQ = "Q3";
const failedAnswer = "My detailed answer that couldn't be processed.";
const contrib = makeContrib(prevQ, "Previous turn answer", 1);
renderFQB({
focused: {
question: "Q4",
answer: null, // no fresh answer to show yet
status: "formulated",
result: null,
error: "Deconstruction failed",
},
focusedAnswer: failedAnswer, // ← this is what the user typed before failure
focusedContributions: [contrib],
processingStep: "idle",
});
// Error message visible
expect(screen.getByText(/unable to process/i)).toBeInTheDocument();
// Retry button visible
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
});
it("derived sections remain during error (priorContribs fallback active)", async () => {
const contrib = makeContrib("Q3", "A3", 1);
renderFQB({
focused: {
question: "Q4",
answer: null,
status: "formulated",
result: null,
error: "Deconstruction failed",
},
focusedContributions: [contrib],
processingStep: "idle",
});
// Derived sections from priorContribs fallback remain visible during error
expect(screen.getByText("What this tells us")).toBeInTheDocument();
});
it("no top-level QUESTION Q4 screen during error", async () => {
const contrib = makeContrib("Q3", "A3", 1);
renderFQB({
focused: {
question: "Q4",
answer: null,
status: "formulated",
result: null,
error: "Deconstruction failed",
},
focusedContributions: [contrib],
processingStep: "idle",
});
// Should NOT show a top-level "Question" heading (follow-up stays in place)
const questionHeadings = screen.queryAllByText(/^Question$/);
expect(questionHeadings).toHaveLength(0);
});
});
// ── v0.51 CASE C: retry returns to processing path ────────────────
describe("retry returns to processing without duplicating follow-up", () => {
it("retry does not clear previous context or duplicate the follow-up question", async () => {
const prevQ = "Which specific step of the onboarding funnel has the highest abandonment rate?";
const prevA = "Step 3 — account verification / phone confirmation.";
const followUpQ = "What drives the Step 3 abandonment rate?";
// Prior contribution includes possibleFollowUpQuestions (real deconstruction always returns them)
const contrib = { ...makeContrib(prevQ, prevA, 1), possibleFollowUpQuestions: [followUpQ] };
// Simulate pre-retry state: error was just retried, processing re-activates
renderFQB({
focused: {
question: followUpQ,
answer: null,
status: "formulated",
result: null, // still null until retry completes
error: null, // cleared by retry before re-submitting
},
focusedContributions: [contrib],
processingStep: "active", // retry re-enters processing
deconstructMsg: "Working through your response…",
});
// Previous completed turn remains visible (from contributions)
expect(screen.getByText("Previously answered")).toBeInTheDocument();
expect(screen.getByText(prevQ)).toBeInTheDocument();
// Follow-up question appears exactly once in active block (not duplicated)
const q4Elements = screen.getAllByText(followUpQ);
expect(q4Elements).toHaveLength(1);
// Processing indicator shows again
expect(screen.getByText(/Working through your response/i)).toBeInTheDocument();
});
});
});