Files
confidence-engine/tests/focused-deconstruct-boundary.test.js
T

453 lines
18 KiB
JavaScript

/**
* Regression: focused deconstruct targetNodeId identity boundary.
*
* Verifies the deterministic enforcement invariant:
* request.targetNodeId (original graph node ID) must be the final
* API response targetNodeId regardless of what the model returns.
*/
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
import { focusedDeconstructJsonSchema, validateFocusedDeconstructSchema } from "@/lib/graph/focused-investigation";
vi.mock("@/lib/supabase/api-auth.js", () => ({
withAuthenticatedApi: (handler) => handler,
}));
// ── helpers ──────────────────────────────────────────────────────────────
function makeMockProvider(inventedTargetNodeId) {
return {
generateReconstruction: vi.fn().mockResolvedValue({
response: {
targetNodeId: inventedTargetNodeId,
observations: ["doc is minimal", "processes in founder's head"],
uncertainties: ["whether formal docs can capture tacit knowledge"],
assumptions: ["documentation is primary mechanism for knowledge transfer"],
relationships: [
{ from: "founder", to: "processes", type: "holds" },
{ from: "ops-context", to: "docs-infra", type: "depends_on" },
],
possibleFollowUpQuestions: [
"What processes does the founder hold tacitly?",
"How is knowledge transferred when founder is unavailable?",
],
},
providerApiPath: "/api/chat",
providerExecution: { chatRequestAttempted: true },
}),
};
}
// ── Boundary test ────────────────────────────────────────────────────────
describe("focused-deconstruct targetNodeId identity boundary", () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
vi.doUnmock("@/lib/llm/provider");
});
it("enforces the canonical focused relationship structure", () => {
const base = {
targetNodeId: "node-id", observations: [], uncertainties: [], assumptions: [],
possibleFollowUpQuestions: [],
};
expect(validateFocusedDeconstructSchema({ ...base, relationships: [] })).toEqual([]);
expect(validateFocusedDeconstructSchema({
...base,
relationships: [{ from: "supplier changed", to: "defect rate increased", type: "associated with" }],
})).toEqual([]);
const invalidRelationships = [
"not an array",
[{}],
[{ from: "a", type: "links" }],
[{ from: "a", to: "b" }],
[{ from: "", to: "b", type: "links" }],
[{ from: "a", to: "", type: "links" }],
[{ from: "a", to: "b", type: "" }],
[{ from: "a", to: "b", type: "links", rationale: "extra" }],
[{ from: "a", to: "b", type: "links", extra: "extra" }],
];
invalidRelationships.forEach((relationships) => {
expect(validateFocusedDeconstructSchema({ ...base, relationships }).length).toBeGreaterThan(0);
});
});
it("request targetNodeId overrides model-invented targetNodeId", async () => {
const requestTargetNodeId = "nk04xvk"; // original graph node ID
const inventedModelId = "invented-model-id";
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => makeMockProvider(inventedModelId),
getProviderModelName: () => "configured-model",
}));
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
const requestBody = {
targetNodeId: requestTargetNodeId,
targetLabel: "Whether unclear or uneven distribution of responsibilities is preventing autonomy in key areas.",
targetDescription: "Original open question node label",
centralStatement: "Current operational context and documentation state",
question:
"What was the comparable state before whether unclear or uneven distribution of responsibilities is preventing autonomy in key areas?",
answer: "Documentation is minimal, most processes are in the head of the founder.",
};
const request = new Request("http://localhost/api/focused-investigation/deconstruct", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestBody),
});
const response = await POST(request);
expect(response.status).toBe(200);
const json = await response.json();
expect(json.success).toBe(true);
// THE INVARIANT: final API targetNodeId = request targetNodeId (authoritative)
expect(json.targetNodeId).toBe(requestTargetNodeId);
expect(json.targetNodeId).not.toBe(inventedModelId);
});
it("supplies the focused-deconstruction schema through the provider seam", async () => {
const generateReconstruction = vi.fn().mockResolvedValue({
response: {
targetNodeId: "model-id",
observations: [],
uncertainties: [],
assumptions: [],
relationships: [],
possibleFollowUpQuestions: [],
},
providerApiPath: "/api/chat",
providerExecution: { chatRequestAttempted: true },
});
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({ generateReconstruction }),
getProviderModelName: () => "gpt-5.6-terra",
}));
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetNodeId: "node-id",
targetLabel: "label",
targetDescription: "description",
centralStatement: "central statement",
question: "question?",
answer: "answer.",
}),
}));
expect(response.status).toBe(200);
expect(generateReconstruction).toHaveBeenCalledWith(
expect.any(String),
"gpt-5.6-terra",
focusedDeconstructJsonSchema,
);
});
it("semantic fields pass through unchanged from model", async () => {
const mockObs = ["doc is minimal", "processes in founder's head"];
const mockUnc = ["whether formal docs can capture tacit knowledge"];
const mockAssm = ["documentation is primary mechanism for knowledge transfer"];
const mockRel = [
{ from: "founder", to: "processes", type: "holds" },
{ from: "ops-context", to: "docs-infra", type: "depends_on" },
];
const mockFuq = [
"What processes does the founder hold tacitly?",
"How is knowledge transferred when founder is unavailable?",
];
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({
generateReconstruction: vi.fn().mockResolvedValue({
response: {
targetNodeId: "some-invented-id",
observations: mockObs,
uncertainties: mockUnc,
assumptions: mockAssm,
relationships: mockRel,
possibleFollowUpQuestions: mockFuq,
},
providerApiPath: "/api/chat",
providerExecution: { chatRequestAttempted: true },
}),
}),
getProviderModelName: () => "configured-model",
}));
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
const request = new Request("http://localhost/api/focused-investigation/deconstruct", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetNodeId: "nk04xvk",
targetLabel: "label",
targetDescription: "desc",
centralStatement: "central",
question: "question?",
answer: "answer.",
}),
});
const response = await POST(request);
const json = await response.json();
// Semantic fields unchanged
expect(json.observations).toEqual(mockObs);
expect(json.uncertainties).toEqual(mockUnc);
expect(json.assumptions).toEqual(mockAssm);
expect(json.relationships).toEqual(mockRel);
expect(json.possibleFollowUpQuestions).toEqual(mockFuq);
});
it("contribution append preserves authoritative targetNodeId", () => {
// Simulates reasoning-workspace.jsx:1178-1189 after the fix:
// onFocusedContribution calls with body.targetNodeId (the original graph node)
const requestTargetNodeId = "nk04xvk";
const inventedModelId = "invented-model-id";
const contribution = {
targetNodeId: requestTargetNodeId,
targetLabel: "label",
targetDescription: "desc",
question: "question?",
answer: "answer.",
observations: ["obs1"],
uncertainties: ["unc1"],
assumptions: ["asm1"],
relationships: [{ from: "a", to: "b", type: "depends_on" }],
possibleFollowUpQuestions: ["fuq1"],
};
expect(contribution.targetNodeId).toBe(requestTargetNodeId);
expect(contribution.targetNodeId).not.toBe(inventedModelId);
// Simulates ThreadContributionsBadge filter: contributions.filter(c => c.targetNodeId === nodeId)
const threadContribs = [contribution].filter((c) => c.targetNodeId === requestTargetNodeId);
expect(threadContribs.length).toBe(1);
});
it("full identity path: request → response → contribution", async () => {
const originalNodeId = "nk04xvk";
const modelInventedId = "investigation_node_responsibility_distribution_autonomy";
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({
generateReconstruction: vi.fn().mockResolvedValue({
response: {
targetNodeId: modelInventedId,
observations: ["Documentation is minimal."],
uncertainties: [],
assumptions: [
"That formal documentation is the primary mechanism for capturing or transferring the founder's tacit knowledge of processes.",
],
relationships: [
{ from: "Founder", to: "Processes", type: "holds" },
{ from: "Operational Context", to: "Documentation Infrastructure", type: "affects" },
],
possibleFollowUpQuestions: [],
},
providerApiPath: "/api/chat",
providerExecution: { chatRequestAttempted: true },
}),
}),
getProviderModelName: () => "configured-model",
}));
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
const request = new Request("http://localhost/api/focused-investigation/deconstruct", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetNodeId: originalNodeId,
targetLabel:
"Whether unclear or uneven distribution of responsibilities is preventing autonomy in key areas.",
targetDescription: "Original open question node description",
centralStatement: "Central statement",
question:
"What was the comparable state before whether unclear or uneven distribution of responsibilities is preventing autonomy in key areas?",
answer: "Documentation is minimal, most processes are in the head of the founder.",
}),
});
const response = await POST(request);
expect(response.status).toBe(200);
const json = await response.json();
// Identity path verification:
// 1. Request targetNodeId
expect(json.targetNodeId).toBe(originalNodeId);
// 2. Response carries authoritative identity (not model-invented)
expect(json.targetNodeId).not.toBe(modelInventedId);
// 3. Semantic fields from the model remain unchanged
expect(json.observations).toEqual(["Documentation is minimal."]);
expect(json.uncertainties).toEqual([]);
expect(json.assumptions).toEqual([
"That formal documentation is the primary mechanism for capturing or transferring the founder's tacit knowledge of processes.",
]);
expect(json.relationships).toEqual([
{ from: "Founder", to: "Processes", type: "holds" },
{ from: "Operational Context", to: "Documentation Infrastructure", type: "affects" },
]);
expect(json.possibleFollowUpQuestions).toEqual([]);
// 4. Stored contribution would use originalNodeId (not modelInventedId)
const stored = { ...json };
expect(stored.targetNodeId).toBe(originalNodeId);
});
it("provider envelope fields do not leak into API response", async () => {
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({
generateReconstruction: vi.fn().mockResolvedValue({
response: {
targetNodeId: "nk04xvk",
observations: ["obs"],
uncertainties: ["unc"],
assumptions: ["asm"],
relationships: [],
possibleFollowUpQuestions: ["fuq"],
},
providerApiPath: "/api/chat",
providerExecution: { chatRequestAttempted: true, chatRequestSucceeded: true },
}),
}),
getProviderModelName: () => "configured-model",
}));
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
const response = await POST(
new Request("http://localhost/api/focused-investigation/deconstruct", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetNodeId: "nk04xvk",
targetLabel: "label",
targetDescription: "desc",
centralStatement: "central",
question: "q?",
answer: "a.",
}),
}),
);
expect(response.status).toBe(200);
const json = await response.json();
// Semantic fields present
expect(json.success).toBe(true);
expect(json.targetNodeId).toBe("nk04xvk");
expect(json.observations).toEqual(["obs"]);
expect(json.possibleFollowUpQuestions).toEqual(["fuq"]);
// Provider envelope fields must NOT appear in the response
expect(json.providerApiPath).toBeUndefined();
expect(json.providerExecution).toBeUndefined();
});
it("preserves the 500 provider-failure contract while logging structural diagnostics", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({
generateReconstruction: vi.fn().mockRejectedValue(Object.assign(new Error("provider failed"), {
providerApiPath: "/v1/responses",
statusCode: 400,
})),
}),
getProviderModelName: () => "gpt-5.6-terra",
}));
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
try {
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetNodeId: "node-id", targetLabel: "label", targetDescription: "description",
centralStatement: "central", question: "question?", answer: "answer.",
}),
}));
expect(response.status).toBe(500);
await expect(response.json()).resolves.toEqual({ error: "provider failed" });
expect(errorSpy).toHaveBeenCalledWith(
"[api/focused-investigation/deconstruct] provider failure",
expect.objectContaining({ targetNodeId: "node-id", providerApiPath: "/v1/responses" }),
);
} finally {
errorSpy.mockRestore();
}
});
it("returns a sanitized 503 when the provider is unavailable", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({
generateReconstruction: vi.fn().mockRejectedValue(Object.assign(new Error(
"Ollama /api/generate request timed out after 5 minutes",
), { code: "PROVIDER_UNAVAILABLE", providerApiPath: "/api/generate" })),
}),
getProviderModelName: () => "configured-model",
}));
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
try {
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetNodeId: "node-id", targetLabel: "label", targetDescription: "description",
centralStatement: "central", question: "question?", answer: "answer.",
}),
}));
expect(response.status).toBe(503);
const json = await response.json();
expect(json).toEqual({ error: "Reasoning service is temporarily unavailable." });
expect(JSON.stringify(json)).not.toMatch(/ollama|generate|timed out/i);
} finally {
errorSpy.mockRestore();
}
});
it("preserves the 502 validation-failure contract with diagnostics", async () => {
vi.doMock("@/lib/llm/provider", () => ({
getProvider: () => ({
generateReconstruction: vi.fn().mockResolvedValue({
response: {}, providerApiPath: "/v1/responses",
}),
}),
getProviderModelName: () => "gpt-5.6-terra",
}));
const { POST } = await import("../app/api/focused-investigation/deconstruct/route.js");
const response = await POST(new Request("http://localhost/api/focused-investigation/deconstruct", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetNodeId: "node-id", targetLabel: "label", targetDescription: "description",
centralStatement: "central", question: "question?", answer: "answer.",
}),
}));
expect(response.status).toBe(502);
await expect(response.json()).resolves.toMatchObject({
success: false,
error: "Focused deconstruction result did not match expected schema",
targetNodeId: "node-id",
});
});
});