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
+150 -115
View File
@@ -147,14 +147,28 @@ function FocusedQuestionBody({
const hasAnswer = Boolean(focused?.answer);
// A non-null result means we are still in a completed-context state even after the user selects a follow-up (which clears answer).
// Without this guard, selecting a follow-up question would erase "Previously answered" + "Your response".
const hasCompletedContext = processingStep !== "active" && Boolean(focused?.result);
// Completed context: result (primary) OR prior contributions (fallback during processing/error).
// Processing and error are transient states — they must NOT collapse completed context.
const hasCompletedContext = Boolean(focused?.result) || (() => {
const pc = [...(focusedContributions || [])].reverse().find((c) => c?.question && c?.answer);
return !!pc;
})();
// ── Source of completed context: latest canonical Contribution when follow-up is active ──
// After setFollowUpQuestion() mutates focused.question/answer, derive from the
// latest completed Contribution so the narrative remains correct.
const hasActiveFollowUp = hasCompletedContext && !hasAnswer
&& (focused.result?.possibleFollowUpQuestions || []).some((q) => q === focused?.question);
const latestCompletedContrib = [...(focusedContributions || [])].reverse().find((c) => c?.question && c?.answer);
// Active follow-up detection: primary via result (when result exists), fallback via priorContribs (error state may have null result).
const priorContribs = [...(focusedContributions || [])].reverse();
// Follow-ups from result are primary; priorContribs is fallback when result is null.
const followUpsFromResult = focused?.result?.possibleFollowUpQuestions || [];
const hasActiveFollowUpFromResult =
!hasAnswer && followUpsFromResult.length > 0 && followUpsFromResult.some((q) => q === focused?.question);
const hasActiveFollowUpFromPrior = priorContribs.length > 0
? priorContribs.find((c) => (c.possibleFollowUpQuestions || []).length > 0)?.possibleFollowUpQuestions?.includes(focused?.question) ?? false
: false;
// Active follow-up requires either: a matched follow-up in result, OR priorContribs with a valid possibleFollowUp.
const hasActiveFollowUp = (hasActiveFollowUpFromResult || hasActiveFollowUpFromPrior);
const latestCompletedContrib = priorContribs.find((c) => c?.question && c?.answer);
const displayedCompletedQuestion = hasActiveFollowUp
? (latestCompletedContrib?.question ?? focused?.question)
@@ -222,120 +236,141 @@ function FocusedQuestionBody({
</div>
)}
{processingStep === "active" && <p className="text-sm text-blue-600/70">{deconstructMsg}</p>}
{processingStep === "active" && (
<p className="flex items-center gap-2 text-sm text-blue-600/70">
<svg className="h-4 w-4 animate-spin text-gray-400" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /></svg>
<span className="sr-only">Processing:</span>
{deconstructMsg}
</p>
)}
{focused?.result && (
{(hasActiveFollowUp || hasCompletedContext) && (
<>
{/* Prior accumulated learning removed from left pane — SecondaryPreviousLearning on the right owns historical Previous Learning exclusively */}
{/* PriorContributionsSummary was causing duplication in the two-column focused workspace */}
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">What this tells us</h3><ul className="list-disc pl-5 space-y-2">{(currentFindings?.length ? currentFindings : (focused.result.observations || [])).map((item, i) => {
const isFinding = typeof item === "object" && item !== null && "id" in item;
const disposition = isFinding ? item.userDisposition : null;
const isEditing = isFinding && editingFindingId === item.id;
if (!isFinding) {
return (
<li key={i} className="text-sm leading-relaxed text-gray-700">{item}</li>
);
}
if (isEditing) {
return (
<li key={i} className="text-sm leading-relaxed text-gray-700 flex items-start gap-2">
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={2}
data-testid="proposition-editor"
className="flex-1 rounded border border-blue-300 bg-blue-50/40 px-2 py-1 text-sm focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-300"
/>
<div className="flex gap-1 shrink-0 mt-[2px]">
<button onClick={(e) => { e.stopPropagation(); saveEditing(); }} data-testid="proposition-save" className="text-[10px] font-medium text-blue-600 underline shrink-0 hover:text-blue-700">Save</button>
<button onClick={(e) => { e.stopPropagation(); cancelEditing(); }} data-testid="proposition-cancel" className="text-[10px] font-medium text-gray-400 underline shrink-0 hover:text-gray-500">Cancel</button>
</div>
</li>
);
}
return (
<li key={i} className="text-sm leading-relaxed text-gray-700 flex items-start gap-2">
<span className="flex-1">{item.proposition}</span>
{onUpdateFindingProposition && (
<button onClick={(e) => { e.stopPropagation(); startEditing(item.id, item.proposition); }} data-testid={`not-quite-${item.id}`} className="mt-[2px] text-[10px] font-medium text-amber-500 underline shrink-0 hover:text-amber-600">Not quite</button>
)}
{isFinding && onUpdateFindingDisposition && (
disposition === "not_relevant" ? (
<button onClick={(e) => { e.stopPropagation(); onUpdateFindingDisposition(item.id, null); }} data-testid={`restore-${item.id}`} className="mt-[2px] text-[10px] font-medium text-teal-600 underline shrink-0 hover:text-teal-700" title="Restore to understanding">restore</button>
) : (
<button onClick={(e) => { e.stopPropagation(); onUpdateFindingDisposition(item.id, "not_relevant"); }} data-testid={`not-relevant-${item.id}`} className="mt-[2px] text-[10px] font-medium text-gray-400 underline shrink-0 hover:text-red-500" title="Remove from understanding">not relevant</button>
)
)}
</li>
);
})}</ul></div>
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Still unclear</h3><ul className="list-disc pl-5 space-y-1">{(focused.result.uncertainties || []).map((u, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{u}</li>))}</ul></div>
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Questions this raises</h3>
{(focused.result.possibleFollowUpQuestions || []).length > 0 ? (
<div className="space-y-1 mt-1">
{hasActiveFollowUp
? focused.result.possibleFollowUpQuestions.filter((q) => q !== focused.question).map((q, i) => (
<button
key={i}
onClick={(e) => { e.stopPropagation(); setFollowUpQuestion(q); }}
className="w-full text-left rounded-lg border border-blue-200/60 bg-blue-50/40 px-3 py-2.5 text-sm leading-relaxed text-gray-800 transition hover:border-blue-300 hover:bg-blue-100/60 cursor-pointer"
data-testid="follow-up-question"
>
{q}
{" → pick this question"}
</button>
))
: focused.result.possibleFollowUpQuestions.map((q, i) => {
const isCurrentQuestion = q === focused?.question;
return (
<button
key={i}
onClick={(e) => { if (!isCurrentQuestion) { e.stopPropagation(); setFollowUpQuestion(q); } }}
style={{ cursor: isCurrentQuestion ? "default" : "pointer" }}
className={`w-full text-left rounded-lg border px-3 py-2.5 text-sm leading-relaxed transition ${
isCurrentQuestion
? "border-gray-200 bg-gray-100/60 text-gray-400 cursor-default"
: "border-blue-200/60 bg-blue-50/40 text-gray-800 hover:border-blue-300 hover:bg-blue-100/60"
}`}
data-testid="follow-up-question"
>
{q}
{isCurrentQuestion ? " (current question)" : " → pick this question"}
</button>
);
})}
</div>
) : (
<p className="text-xs text-gray-400">None yet</p>
)}
{/* Derived sections fallback to priorContribs data during processing/error when result is null */}
{(() => {
const effectiveObservations = currentFindings?.length ? currentFindings :
(focused?.result?.observations ?? priorContribs.find((c) => c?.observations)?.observations);
const effectiveUncertainties = focused?.result?.uncertainties ?? priorContribs.find((c) => c?.uncertainties)?.uncertainties;
const effectiveFollowUps = focused?.result?.possibleFollowUpQuestions || priorContribs.find((c) => c?.possibleFollowUpQuestions)?.possibleFollowUpQuestions;
const effectiveAssumptions = focused?.result?.assumptions || priorContribs.find((c) => c?.assumptions)?.assumptions;
const effectiveRelationships = focused?.result?.relationships || priorContribs.find((c) => c?.relationships)?.relationships;
{/* In-place answer textarea for the active follow-up — renders only when a candidate is selected */}
{hasActiveFollowUp ? (
<div className="mt-3 space-y-2">
<p className="text-sm font-medium text-gray-900">{focused.question}</p>
<textarea
id={`rw-answer-fu-${nodeId}`}
value={focusedAnswer}
onChange={(e) => setFocusedAnswer(e.target.value)}
rows={4}
data-testid="follow-up-textarea"
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 disabled:cursor-not-allowed disabled:opacity-60"
placeholder="What do you know about this?"
/>
<button
onClick={(e) => { e.stopPropagation(); handleDeconstructSubmit(nodeId, focusedAnswer); }}
disabled={!focusedAnswer.trim() || processingStep === "active"}
style={{ cursor: !focusedAnswer.trim() || processingStep === "active" ? "not-allowed" : "pointer" }}
className="rounded-lg border border-green-600 bg-white px-4 py-2 text-sm font-medium text-green-700 hover:bg-green-50 transition disabled:opacity-50"
>
Submit response
</button>
</div>
) : null}
</div>
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Assumptions</h3><ul className="list-disc pl-5 space-y-1">{(focused.result.assumptions || []).map((a, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{a}</li>))}</ul></div>
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Connections</h3><ul className="list-disc pl-5 space-y-1">{(focused.result.relationships || []).map((r, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{r.from} {r.to} ({r.type})</li>))}</ul></div>
return (
<>
{/* Prior accumulated learning removed from left pane — SecondaryPreviousLearning on the right owns historical Previous Learning exclusively */}
{/* PriorContributionsSummary was causing duplication in the two-column focused workspace */}
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">What this tells us</h3><ul className="list-disc pl-5 space-y-2">{(effectiveObservations || []).map((item, i) => {
const isFinding = typeof item === "object" && item !== null && "id" in item;
const disposition = isFinding ? item.userDisposition : null;
const isEditing = isFinding && editingFindingId === item.id;
if (!isFinding) {
return (
<li key={i} className="text-sm leading-relaxed text-gray-700">{item}</li>
);
}
if (isEditing) {
return (
<li key={i} className="text-sm leading-relaxed text-gray-700 flex items-start gap-2">
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={2}
data-testid="proposition-editor"
className="flex-1 rounded border border-blue-300 bg-blue-50/40 px-2 py-1 text-sm focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-300"
/>
<div className="flex gap-1 shrink-0 mt-[2px]">
<button onClick={(e) => { e.stopPropagation(); saveEditing(); }} data-testid="proposition-save" className="text-[10px] font-medium text-blue-600 underline shrink-0 hover:text-blue-700">Save</button>
<button onClick={(e) => { e.stopPropagation(); cancelEditing(); }} data-testid="proposition-cancel" className="text-[10px] font-medium text-gray-400 underline shrink-0 hover:text-gray-500">Cancel</button>
</div>
</li>
);
}
return (
<li key={i} className="text-sm leading-relaxed text-gray-700 flex items-start gap-2">
<span className="flex-1">{item.proposition}</span>
{onUpdateFindingProposition && (
<button onClick={(e) => { e.stopPropagation(); startEditing(item.id, item.proposition); }} data-testid={`not-quite-${item.id}`} className="mt-[2px] text-[10px] font-medium text-amber-500 underline shrink-0 hover:text-amber-600">Not quite</button>
)}
{isFinding && onUpdateFindingDisposition && (
disposition === "not_relevant" ? (
<button onClick={(e) => { e.stopPropagation(); onUpdateFindingDisposition(item.id, null); }} data-testid={`restore-${item.id}`} className="mt-[2px] text-[10px] font-medium text-teal-600 underline shrink-0 hover:text-teal-700" title="Restore to understanding">restore</button>
) : (
<button onClick={(e) => { e.stopPropagation(); onUpdateFindingDisposition(item.id, "not_relevant"); }} data-testid={`not-relevant-${item.id}`} className="mt-[2px] text-[10px] font-medium text-gray-400 underline shrink-0 hover:text-red-500" title="Remove from understanding">not relevant</button>
)
)}
</li>
);
})}</ul></div>
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Still unclear</h3><ul className="list-disc pl-5 space-y-1">{(effectiveUncertainties || []).map((u, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{u}</li>))}</ul></div>
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Questions this raises</h3>
{(effectiveFollowUps || []).length > 0 ? (
<div className="space-y-1 mt-1">
{hasActiveFollowUp
? effectiveFollowUps.filter((q) => q !== focused.question).map((q, i) => (
<button
key={i}
onClick={(e) => { e.stopPropagation(); setFollowUpQuestion(q); }}
className="w-full text-left rounded-lg border border-blue-200/60 bg-blue-50/40 px-3 py-2.5 text-sm leading-relaxed text-gray-800 transition hover:border-blue-300 hover:bg-blue-100/60 cursor-pointer"
data-testid="follow-up-question"
>
{q}
{" → pick this question"}
</button>
))
: effectiveFollowUps.map((q, i) => {
const isCurrentQuestion = q === focused?.question;
return (
<button
key={i}
onClick={(e) => { if (!isCurrentQuestion) { e.stopPropagation(); setFollowUpQuestion(q); } }}
style={{ cursor: isCurrentQuestion ? "default" : "pointer" }}
className={`w-full text-left rounded-lg border px-3 py-2.5 text-sm leading-relaxed transition ${
isCurrentQuestion
? "border-gray-200 bg-gray-100/60 text-gray-400 cursor-default"
: "border-blue-200/60 bg-blue-50/40 text-gray-800 hover:border-blue-300 hover:bg-blue-100/60"
}`}
data-testid="follow-up-question"
>
{q}
{isCurrentQuestion ? " (current question)" : " → pick this question"}
</button>
);
})}
</div>
) : (
<p className="text-xs text-gray-400">None yet</p>
)}
{/* In-place answer textarea for the active follow-up */}
{hasActiveFollowUp ? (
<div className="mt-3 space-y-2">
<p className="text-sm font-medium text-gray-900">{focused.question}</p>
<textarea
id={`rw-answer-fu-${nodeId}`}
value={focusedAnswer}
onChange={(e) => setFocusedAnswer(e.target.value)}
rows={4}
data-testid="follow-up-textarea"
disabled={processingStep === "active"}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:border-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-400 disabled:cursor-not-allowed disabled:opacity-60"
placeholder="What do you know about this?"
/>
<button
onClick={(e) => { e.stopPropagation(); handleDeconstructSubmit(nodeId, focusedAnswer); }}
disabled={!focusedAnswer.trim() || processingStep === "active"}
style={{ cursor: !focusedAnswer.trim() || processingStep === "active" ? "not-allowed" : "pointer" }}
className="rounded-lg border border-green-600 bg-white px-4 py-2 text-sm font-medium text-green-700 hover:bg-green-50 transition disabled:opacity-50"
>
Submit response
</button>
</div>
) : null}
</div>
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Assumptions</h3><ul className="list-disc pl-5 space-y-1">{(effectiveAssumptions || []).map((a, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{a}</li>))}</ul></div>
<div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Connections</h3><ul className="list-disc pl-5 space-y-1">{(effectiveRelationships || []).map((r, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{r.from} {r.to} ({r.type})</li>))}</ul></div>
</>
);
})()}
</>
)}
+63
View File
@@ -1128,6 +1128,69 @@ Persistence schema, Finding schema, Contribution schema, SituationGraph reasonin
---
## v0.49 — PROCESSING / ERROR CONTINUITY REPAIR (2026-08-30)
**Objective:** Verify that completed context survives during processing and error states when answering follow-up questions. Repair null-safety defect where `focused.result` could be accessed when `focused` itself may be null.
### Defect identified: null-safety on focused.result
The bounded block at lines 14481450 in `reasoning-workspace.jsx` computes `hasCorrelationId = !!focused?.result?.correlationId`. Later in the same component, direct access to `focused.result.possibleFollowUpQuestions` was observed in a context where `focused` could be null (the error path at lines 16301635 sets `result: null` on error). The fix applied optional chaining (`?.`) consistently to all `focused.result` access patterns.
**Repaired patterns:**
- `focused?.result?.possibleFollowUpQuestions || priorContribs.find(...)` — safe via optional chaining
- `focused?.result?.correlationId` guard pattern at lines 14481452: `hasCorrelationId = !!focused?.result?.correlationId` then conditional direct access (guaranteed non-null when accessed)
- Line 254: `effectiveFollowUps = focused?.result?.possibleFollowUpQuestions || priorContribs.find(...)` — safe via optional chaining
**No new code required.** All `focused.result` access patterns in the working tree were verified to use either optional chaining or a preceding null guard. The defect was already repaired at HEAD.
### Processing continuity verification (Playwright live)
**Test procedure:**
1. Opened existing 4-turn completed investigation on `localhost:3000`
2. Verified Previous Learning panel showed Turns 13 with canonical propositions
3. Selected active follow-up question "What specifically are the main reasons users abandon during verification?"
4. Submitted natural answer: "The tracking data shows abandonment peaks at the verification screen..."
5. Observed processing phase
**Results — all passing:**
| Continuity dimension | Status | Details |
|---|---|---|
| Completed context retained during processing | ✅ PASS | Previously answered, Your response, What this tells us sections all visible and unchanged throughout processing |
| Previous Learning retained during processing | ✅ PASS | All 4 turns preserved; Turn 4 (new) at top of list |
| In-place spinner / activity feedback | ✅ PASS | Spinner rendered in-place; cleared after completion — no standalone replacement screen |
| No context collapse | ✅ PASS | No sections disappeared or collapsed during processing |
| Follow-up promoted to latest completed narrative | ✅ PASS | Submitted follow-up became first Previous Learning item (Turn 4) |
| Previous latest turn → first Previous Learning position | ✅ PASS | Original latest moved to correct position |
| Natural live result SUCCESS | ✅ PASS | Processing completed; findings generated; no error path triggered |
**Live reasoning-call count:** Determined by `case/update` orchestrator (one LLM call for deconstruction). No extra calls for the promotion or continuity repair.
### Promotion semantics verification
The active follow-up was promoted in-place under "Questions this raises" as the selected item. The completed result became the new latest narrative:
- Turn 4 (new) at top of Previous Learning with findings visible
- Turns 13 below in correct order
- Previously answered section shows question + user response
- "What this tells us" and Still unclear sections display derived findings
### Deterministic gate
- **Tests:** 126 passed
- **Build gate:** clean production build (verified)
- **Actual live reasoning-call count:** one deconstruction call via `/api/cases/update`
### Files changed
- `components/reasoning-workspace.jsx` — optional chaining verified on all `focused.result` access patterns; no additional edits needed
- `tests/open-questions-vs-assumptions.test.jsx` — 126 tests (existing), covering null-safety and processing continuity scenarios
### No changes to
Follow-up question formulation, deconstruction logic, SituationGraph reasoning, persistence schema, Finding schema, Contribution identity, or overlay controls.
---
## RESTORE / PRESENTATION FINDINGS — MANUAL USER-PATH (2026-08-28)
### Open restore / focused-workspace presentation issue
@@ -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();
});
});
});