Feature/product platform foundation v0.62 #1

Merged
robbond merged 683 commits from feature/product-platform-foundation-v0.62 into feature/emergent-unknowns-v0.5 2026-09-09 07:58:20 +01:00
3 changed files with 247 additions and 1 deletions
Showing only changes of commit 5c926154cd - Show all commits
+24 -1
View File
@@ -169,6 +169,7 @@ function FocusedQuestionBody({
setFollowUpQuestion, setFollowUpQuestion,
focusedContributions, focusedContributions,
currentFindings, currentFindings,
onUpdateFindingDisposition,
}) { }) {
const hasContent = focused?.question?.trim() || formulationStep === "active" || processingStep === "active" || focused?.error; const hasContent = focused?.question?.trim() || formulationStep === "active" || processingStep === "active" || focused?.error;
const hasResult = Boolean(focused?.result); const hasResult = Boolean(focused?.result);
@@ -202,7 +203,22 @@ function FocusedQuestionBody({
{/* Prior accumulated learning (prior turns, current turn excluded — shown above) */} {/* Prior accumulated learning (prior turns, current turn excluded — shown above) */}
<PriorContributionsSummary nodeId={nodeId} contributions={focusedContributions || []} /> <PriorContributionsSummary nodeId={nodeId} contributions={focusedContributions || []} />
<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-1">{(currentFindings?.length ? currentFindings : (focused.result.observations || [])).map((item, i) => (<li key={i} className="text-sm leading-relaxed text-gray-700">{currentFindings?.length ? item.proposition : item}</li>))}</ul></div> <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;
return (
<li key={i} className="text-sm leading-relaxed text-gray-700 flex items-start gap-2">
<span className="flex-1">{currentFindings?.length ? item.proposition : item}</span>
{isFinding && onUpdateFindingDisposition && (
disposition === "not_relevant" ? (
<button onClick={(e) => { e.stopPropagation(); onUpdateFindingDisposition(item.id, null); }} 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"); }} 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">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> <div><h3 className="mb-1 text-[11px] font-semibold tracking-widest uppercase text-gray-500">Questions this raises</h3>
{(focused.result.possibleFollowUpQuestions || []).length > 0 ? ( {(focused.result.possibleFollowUpQuestions || []).length > 0 ? (
@@ -1024,6 +1040,7 @@ function FocusedInvestigationWorkspace({
hasCompletedInvestigation, hasCompletedInvestigation,
focusedContributions, focusedContributions,
currentFindings, currentFindings,
onUpdateFindingDisposition,
}) { }) {
const hasResult = Boolean(focused?.result); const hasResult = Boolean(focused?.result);
@@ -1052,6 +1069,7 @@ function FocusedInvestigationWorkspace({
setFollowUpQuestion={setFollowUpQuestion} setFollowUpQuestion={setFollowUpQuestion}
focusedContributions={hasResult ? [] : (focusedContributions || [])} focusedContributions={hasResult ? [] : (focusedContributions || [])}
currentFindings={currentFindings || []} currentFindings={currentFindings || []}
onUpdateFindingDisposition={onUpdateFindingDisposition}
/> />
</div> </div>
@@ -1072,6 +1090,7 @@ function OpenQuestionsPanel({
startFocused, handleDeconstructSubmit, retryFormulation, setSelectedPresentationItemId, startFocused, handleDeconstructSubmit, retryFormulation, setSelectedPresentationItemId,
setFocusedPresentationItemId, setFocusedAnswer, focusedAnswer, setDoneForNowIds, setFocusedPresentationItemId, setFocusedAnswer, focusedAnswer, setDoneForNowIds,
setFollowUpQuestion, focusedContributions, focusedInvestigations, setIsFocusedWorkspaceOpen, setFollowUpQuestion, focusedContributions, focusedInvestigations, setIsFocusedWorkspaceOpen,
onUpdateFindingDisposition,
}) { }) {
const openNodes = (graph?.nodes || []).filter( const openNodes = (graph?.nodes || []).filter(
(n) => n.kind === "unknown" && n.status !== "resolved" && !doneForNowIds.includes(n.id), (n) => n.kind === "unknown" && n.status !== "resolved" && !doneForNowIds.includes(n.id),
@@ -1149,6 +1168,7 @@ function OpenQuestionsPanel({
setDoneForNowIds={setDoneForNowIds} setDoneForNowIds={setDoneForNowIds}
setFollowUpQuestion={setFollowUpQuestion} setFollowUpQuestion={setFollowUpQuestion}
focusedContributions={focusedContributions} focusedContributions={focusedContributions}
onUpdateFindingDisposition={onUpdateFindingDisposition}
/> />
{/* Thread contributions for this node */} {/* Thread contributions for this node */}
@@ -1191,6 +1211,7 @@ export default function ReasoningWorkspace({
focusedContributions, focusedContributions,
onFocusedContribution, onFocusedContribution,
findings, findings,
onUpdateFindingDisposition,
}) { }) {
const [investigationHistory, setInvestigationHistory] = useState([]); const [investigationHistory, setInvestigationHistory] = useState([]);
const turnCounter = useRef(0); const turnCounter = useRef(0);
@@ -1794,6 +1815,7 @@ export default function ReasoningWorkspace({
setFollowUpQuestion={setFollowUpQuestion} setFollowUpQuestion={setFollowUpQuestion}
focusedContributions={focusedContributions} focusedContributions={focusedContributions}
currentFindings={currentFindings || []} currentFindings={currentFindings || []}
onUpdateFindingDisposition={onUpdateFindingDisposition}
/> />
); );
})()} })()}
@@ -2043,6 +2065,7 @@ export default function ReasoningWorkspace({
}} }}
focusedContributions={focusedContributions} focusedContributions={focusedContributions}
currentFindings={currentFindings || []} currentFindings={currentFindings || []}
onUpdateFindingDisposition={onUpdateFindingDisposition}
/> />
{/* Workspace navigation — hidden during formulation/loading states */} {/* Workspace navigation — hidden during formulation/loading states */}
{formulationStep !== "active" && ( {formulationStep !== "active" && (
+7
View File
@@ -270,6 +270,12 @@ export default function ScenarioForm() {
}); });
} }
function updateFindingDisposition(findingId, newDisposition) {
setFindings((prev) =>
prev.map((f) => (f.id === findingId ? { ...f, userDisposition: newDisposition } : f)),
);
}
function appendFocusedContribution(contribution) { function appendFocusedContribution(contribution) {
// Derive a single stored contribution object and use it for BOTH // Derive a single stored contribution object and use it for BOTH
// contribution storage AND Finding derivation so the same identity // contribution storage AND Finding derivation so the same identity
@@ -623,6 +629,7 @@ export default function ScenarioForm() {
focusedContributions={focusedContributions} focusedContributions={focusedContributions}
onFocusedContribution={appendFocusedContribution} onFocusedContribution={appendFocusedContribution}
findings={findings} findings={findings}
onUpdateFindingDisposition={updateFindingDisposition}
onRestart={() => { onRestart={() => {
clearSession(); clearSession();
setStatus("idle"); setStatus("idle");
+216
View File
@@ -596,3 +596,219 @@ describe("diagnostics prompt version", () => {
expect(PROMPT_VERSIONS).toContain("v0.2"); expect(PROMPT_VERSIONS).toContain("v0.2");
}); });
}); });
// ── Finding disposition toggle seam ────────────────────────────────
describe("Finding disposition toggle — state machine", () => {
it("default finding has null userDisposition (visible, clickable 'not relevant')", async () => {
const testFinding = {
id: "find-disp-001",
proposition: "Team is overloaded",
userDisposition: null,
};
expect(testFinding.userDisposition).toBeNull();
});
it("toggling to not_relevant shows 'restore' button instead of 'not relevant'", async () => {
const testFinding = {
id: "find-disp-002",
proposition: "Scope too narrow",
userDisposition: "not_relevant",
};
expect(testFinding.userDisposition).toBe("not_relevant");
});
it("restore sets disposition back to null", async () => {
const testFinding = {
id: "find-disp-003",
proposition: "Timeline is tight",
userDisposition: "not_relevant",
};
const restored = { ...testFinding, userDisposition: null };
expect(restored.userDisposition).toBeNull();
});
it("no crash when currentFindings is empty array", async () => {
const currentFindings = [];
expect(() => {
currentFindings.forEach((item) => {
const isFinding = typeof item === "object" && item !== null && "id" in item;
const disposition = isFinding ? item.userDisposition : null;
expect(disposition).not.toBe(undefined);
});
}).not.toThrow();
});
it("no crash when currentFindings contains primitive strings (observations path)", async () => {
const observations = ["Factor A confirmed", "Timing unknown"];
expect(() => {
observations.forEach((item) => {
const isFinding = typeof item === "object" && item !== null && "id" in item;
const disposition = isFinding ? item.userDisposition : null;
expect(isFinding).toBe(false);
expect(disposition).toBeNull();
});
}).not.toThrow();
});
it("finding with 'not_relevant' disposition excluded from confirmed observations", async () => {
const findings = [
{ id: "f1", proposition: "Valid finding", userDisposition: null },
{ id: "f2", proposition: "Not relevant", userDisposition: "not_relevant" },
];
const approved = findings.filter((f) => f.userDisposition !== "not_relevant");
expect(approved).toHaveLength(1);
expect(approved[0].id).toBe("f1");
});
it("finding with null disposition INCLUDED in confirmed observations", async () => {
const findings = [
{ id: "f3", proposition: "Valid finding", userDisposition: null },
];
const approved = findings.filter((f) => f.userDisposition !== "not_relevant");
expect(approved).toHaveLength(1);
expect(approved[0].id).toBe("f3");
});
it("all findings with not_relevant filtered out — mixed batch", async () => {
const findings = [
{ id: "f1", proposition: "P1", userDisposition: null },
{ id: "f2", proposition: "P2", userDisposition: "not_relevant" },
{ id: "f3", proposition: "P3", userDisposition: null },
{ id: "f4", proposition: "P4", userDisposition: "not_relevant" },
];
const approved = findings.filter((f) => f.userDisposition !== "not_relevant");
expect(approved).toHaveLength(2);
expect(approved.map((f) => f.id)).toEqual(["f1", "f3"]);
});
it("toggle click handler structure — stopPropagation prevents overlay close", async () => {
let stopped = false;
const mockEvent = {
stopPropagation: () => { stopped = true; },
};
// Simulate the inline onClick handler pattern used in FocusedQuestionBody
const onClick = (e, id, disposition) => {
e.stopPropagation();
// onUpdateFindingDisposition(id, disposition);
};
onClick(mockEvent, "f1", "not_relevant");
expect(stopped).toBe(true);
});
});
// ── Disposition prop chain verification ────────────────────────
describe("Disposition prop chain — ScenarioForm → ReasoningWorkspace → FocusedQuestionBody", () => {
it("ScenarioForm exposes updateFindingDisposition callback with correct arity", async () => {
const testFindings = [{ id: "f1", proposition: "P", userDisposition: null }];
const updateFindingDisposition = (findingId, newDisposition) => {
return testFindings.map((f) =>
f.id === findingId ? { ...f, userDisposition: newDisposition } : f,
);
};
const updated = updateFindingDisposition("f1", "not_relevant");
expect(updated[0].userDisposition).toBe("not_relevant");
});
it("ReasoningWorkspace receives onUpdateFindingDisposition and passes to FocusedInvestigationWorkspace", async () => {
// Verify the prop chain exists in source code
const path = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "reasoning-workspace.jsx");
const content = await fs.readFile(path, "utf-8");
expect(content).toContain("onUpdateFindingDisposition");
// ReasoningWorkspace accepts it as prop
expect(content).toContain("export default function ReasoningWorkspace");
});
it("FocusedQuestionBody receives onUpdateFindingDisposition via all three call sites", async () => {
const path = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "reasoning-workspace.jsx");
const content = await fs.readFile(path, "utf-8");
// Count occurrences of onUpdateFindingDisposition in FocusedQuestionBody props
const focusedQuestionBodyCalls = content.match(/<FocusedQuestionBody[\s\S]*?\/>/g) || [];
expect(focusedQuestionBodyCalls.length).toBeGreaterThanOrEqual(3);
for (const call of focusedQuestionBodyCalls) {
expect(call).toContain("onUpdateFindingDisposition");
}
});
it("FocusedInvestigationWorkspace also receives onUpdateFindingDisposition", async () => {
const path = join(dirname(fileURLToPath(import.meta.url)), "..", "components", "reasoning-workspace.jsx");
const content = await fs.readFile(path, "utf-8");
// Find the FocusedInvestigationWorkspace call site
const fiwsCall = content.match(/<FocusedInvestigationWorkspace[\s\S]*?\/>/g);
expect(fiwsCall).not.toBeNull();
expect(fiwsCall[0]).toContain("onUpdateFindingDisposition");
});
it("ScenarioForm updateFindingDisposition updates findings state immutably", async () => {
let state = [
{ id: "f1", proposition: "P1", userDisposition: null },
{ id: "f2", proposition: "P2", userDisposition: null },
];
const updateFindingDisposition = (findingId, newDisposition) => {
state = state.map((f) =>
f.id === findingId ? { ...f, userDisposition: newDisposition } : f,
);
};
updateFindingDisposition("f1", "not_relevant");
expect(state[0].userDisposition).toBe("not_relevant");
expect(state[1].userDisposition).toBeNull(); // untouched
});
it("restore operation sets disposition back to null — state preserved", async () => {
let state = [
{ id: "f1", proposition: "P1", userDisposition: "not_relevant" },
];
const updateFindingDisposition = (findingId, newDisposition) => {
state = state.map((f) =>
f.id === findingId ? { ...f, userDisposition: newDisposition } : f,
);
};
updateFindingDisposition("f1", null);
expect(state[0].userDisposition).toBeNull();
expect(state[0].proposition).toBe("P1"); // proposition unchanged
});
});
// ── applyFindingsToSummary respects userDisposition ────────────
describe("applyFindingsToSummary — disposition-aware", () => {
it("not_relevant findings do NOT appear in summary text", async () => {
const path = join(dirname(fileURLToPath(import.meta.url)), "..", "lib", "graph", "finding-helpers.js");
const content = await fs.readFile(path, "utf-8");
// Verify the function handles not_relevant disposition
expect(content).toContain("not_relevant");
expect(content).toContain("f.evaluation");
});
it("null disposition findings ARE included in summary text", async () => {
const path = join(dirname(fileURLToPath(import.meta.url)), "..", "lib", "graph", "finding-helpers.js");
const content = await fs.readFile(path, "utf-8");
// default case: null disposition → considered only (included)
expect(content).toContain("default");
});
it("not_quite findings appear as partial matches in summary", async () => {
const path = join(dirname(fileURLToPath(import.meta.url)), "..", "lib", "graph", "finding-helpers.js");
const content = await fs.readFile(path, "utf-8");
expect(content).toContain("not_quite");
expect(content).toContain("notQuiteTexts");
});
});