419 lines
16 KiB
JavaScript
419 lines
16 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";
|
|
|
|
// ── 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("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",
|
|
});
|
|
});
|
|
});
|