|
|
|
@@ -0,0 +1,575 @@
|
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
|
|
|
import selectBehaviour, { BEHAVIOUR_OPTIONS } from "@/lib/behaviour-selection/behaviour-selector.js";
|
|
|
|
|
|
|
|
|
|
/* ── Helper: build minimal assessment objects for test data ─ */
|
|
|
|
|
|
|
|
|
|
function mkAssessment(opts = {}) {
|
|
|
|
|
return {
|
|
|
|
|
version: "v0.1",
|
|
|
|
|
assessedAt: new Date().toISOString(),
|
|
|
|
|
confidence: opts.overallConfidence || "medium",
|
|
|
|
|
phase: opts.phase ?? {
|
|
|
|
|
value: "cannot_determine",
|
|
|
|
|
confidence: "low",
|
|
|
|
|
signals: [],
|
|
|
|
|
evidence: { resolvedNodeCount: 0, activeUnknownCount: 0, unknownResolutionRatio: null, observationDensity: 0, evidenceDepth: "insufficient" }
|
|
|
|
|
},
|
|
|
|
|
progress: opts.progress ?? {
|
|
|
|
|
value: "cannot_determine",
|
|
|
|
|
confidence: "low",
|
|
|
|
|
signals: [],
|
|
|
|
|
evidence: { turnCount: 0, recentResolutionsLastTurn: 0, newUnknownsPerTurn: null, repeatedNodeIds: [] }
|
|
|
|
|
},
|
|
|
|
|
conversationHealth: opts.conversationHealth ?? {
|
|
|
|
|
value: "cannot_determine",
|
|
|
|
|
confidence: "low",
|
|
|
|
|
signals: [],
|
|
|
|
|
evidence: { questionTypeDistribution: null, activeUnknownCount: 0, resolvedNodeRatio: null, hasActiveQuestion: false, summaryLength: 0 }
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Behaviour option contract tests ───────────────────────── */
|
|
|
|
|
|
|
|
|
|
describe("Behaviour options", () => {
|
|
|
|
|
it("exactly five behaviours are declared", () => {
|
|
|
|
|
expect(BEHAVIOUR_OPTIONS).toHaveLength(5);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("contains all five required behaviours", () => {
|
|
|
|
|
const expected = ["acknowledge", "clarify", "summarise", "continue", "pause"];
|
|
|
|
|
for (const b of expected) {
|
|
|
|
|
expect(BEHAVIOUR_OPTIONS).toContain(b);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/* ── Contract conformance tests ───────────────────────────── */
|
|
|
|
|
|
|
|
|
|
describe("Contract conformance", () => {
|
|
|
|
|
it("returns an object with behaviour, confidence, reason", () => {
|
|
|
|
|
const result = selectBehaviour(null);
|
|
|
|
|
expect(result).toHaveProperty("behaviour");
|
|
|
|
|
expect(result).toHaveProperty("confidence");
|
|
|
|
|
expect(result).toHaveProperty("reason");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("behaviour is one of the five declared options", () => {
|
|
|
|
|
const result = selectBehaviour(null);
|
|
|
|
|
expect(BEHAVIOUR_OPTIONS).toContain(result.behaviour);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("confidence is low / medium / high", () => {
|
|
|
|
|
for (const conf of ["high", "medium", "low"]) {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({ phase: { ...mkAssessment().phase, confidence: conf }, progress: { ...mkAssessment().progress, confidence: conf }, conversationHealth: { ...mkAssessment().conversationHealth, value: conf } }));
|
|
|
|
|
expect(["low", "medium", "high"]).toContain(result.confidence);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("reason is a non-empty string (not chain-of-thought)", () => {
|
|
|
|
|
const result = selectBehaviour(null);
|
|
|
|
|
expect(typeof result.reason).toBe("string");
|
|
|
|
|
expect(result.reason.length).toBeGreaterThan(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("has a priority field (internal, for debugging)", () => {
|
|
|
|
|
const result = selectBehaviour(null);
|
|
|
|
|
expect(typeof result.priority).toBe("number");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("deterministic — identical inputs always produce identical behaviour", () => {
|
|
|
|
|
const assessment = mkAssessment({ phase: { value: "exploring", confidence: "medium" }, progress: { value: "steady", confidence: "medium" } });
|
|
|
|
|
const results = Array.from({ length: 10 }, () => selectBehaviour(assessment));
|
|
|
|
|
// All should match the first
|
|
|
|
|
for (const r of results) {
|
|
|
|
|
expect(r.behaviour).toBe(results[0].behaviour);
|
|
|
|
|
expect(r.reason).toBe(results[0].reason);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("has a priority field (internal, for debugging)", () => {
|
|
|
|
|
const result = selectBehaviour(null);
|
|
|
|
|
expect(typeof result.priority).toBe("number");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("every possible behaviour can be triggered", () => {
|
|
|
|
|
const acknowledge = selectBehaviour(mkAssessment({ phase: { value: "exploring", confidence: "medium" }, conversationHealth: { value: "healthy", confidence: "high" } }));
|
|
|
|
|
expect(acknowledge.behaviour).toBe("acknowledge");
|
|
|
|
|
|
|
|
|
|
const clarify = selectBehaviour(mkAssessment({ phase: { value: "orienting", confidence: "medium" }, progress: { value: "cannot_determine", confidence: "low" }, conversationHealth: { value: "too_broad", confidence: "high" } }));
|
|
|
|
|
expect(clarify.behaviour).toBe("clarify");
|
|
|
|
|
|
|
|
|
|
const summarise = selectBehaviour(mkAssessment({ phase: { value: "synthesising", confidence: "high" }, progress: { value: "steady", confidence: "medium" } }));
|
|
|
|
|
expect(summarise.behaviour).toBe("summarise");
|
|
|
|
|
|
|
|
|
|
// Pause test — health must NOT be healthy (or acknowledge fires first) and NOT too_broad (or clarify fires first)
|
|
|
|
|
const pause = selectBehaviour(mkAssessment({ phase: { value: "focusing", confidence: "high" }, progress: { value: "stalled", confidence: "high" }, conversationHealth: { value: "user_overloaded", confidence: "medium" } }));
|
|
|
|
|
expect(pause.behaviour).toBe("pause");
|
|
|
|
|
|
|
|
|
|
const continue_ = selectBehaviour(mkAssessment({ phase: { value: "exploring", confidence: "low" }, progress: { value: "cannot_determine", confidence: "low" }, conversationHealth: { value: "cannot_determine", confidence: "low" } }));
|
|
|
|
|
expect(continue_.behaviour).toBe("continue");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/* ── Acknowledge rule tests ─────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
describe("Acknowledge rule", () => {
|
|
|
|
|
it("fires when conversation health is healthy and phase has confidence", () => {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "focusing", confidence: "high" },
|
|
|
|
|
progress: { value: "steady", confidence: "medium" },
|
|
|
|
|
conversationHealth: { value: "healthy", confidence: "high" }
|
|
|
|
|
}));
|
|
|
|
|
expect(result.behaviour).toBe("acknowledge");
|
|
|
|
|
expect(result.priority).toBe(1); // highest priority
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not fire when phase confidence is low (insufficient evidence)", () => {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "focusing", confidence: "low" },
|
|
|
|
|
progress: { value: "steady", confidence: "medium" },
|
|
|
|
|
conversationHealth: { value: "healthy", confidence: "high" }
|
|
|
|
|
}));
|
|
|
|
|
expect(result.behaviour).not.toBe("acknowledge");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not fire when conversation health is not healthy", () => {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "focusing", confidence: "high" },
|
|
|
|
|
progress: { value: "steady", confidence: "medium" },
|
|
|
|
|
conversationHealth: { value: "too_broad", confidence: "high" }
|
|
|
|
|
}));
|
|
|
|
|
expect(result.behaviour).not.toBe("acknowledge");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("reason is developer-facing and explanatory", () => {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "exploring", confidence: "medium" },
|
|
|
|
|
progress: { value: "steady", confidence: "high" },
|
|
|
|
|
conversationHealth: { value: "healthy", confidence: "high" }
|
|
|
|
|
}));
|
|
|
|
|
expect(result.behaviour).toBe("acknowledge");
|
|
|
|
|
// Not chain-of-thought — should be a plain English explanation
|
|
|
|
|
expect(result.reason.toLowerCase()).not.toContain("chain of thought");
|
|
|
|
|
expect(result.reason.toLowerCase()).not.toContain("therefore");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/* ── Clarify rule tests ────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
describe("Clarify rule", () => {
|
|
|
|
|
it("fires when conversation health is too_broad", () => {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "exploring", confidence: "medium" },
|
|
|
|
|
progress: { value: "steady", confidence: "medium" },
|
|
|
|
|
conversationHealth: { value: "too_broad", confidence: "high" }
|
|
|
|
|
}));
|
|
|
|
|
expect(result.behaviour).toBe("clarify");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("fires when phase is orienting with insufficient observations (< 3)", () => {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "orienting", confidence: "medium", evidence: { observationDensity: 2 } },
|
|
|
|
|
progress: { value: "cannot_determine", confidence: "low" },
|
|
|
|
|
conversationHealth: { value: "too_narrow", confidence: "low" }
|
|
|
|
|
}));
|
|
|
|
|
expect(result.behaviour).toBe("clarify");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not fire when phase is orienting but has sufficient observations (≥ 3)", () => {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "orienting", confidence: "high" },
|
|
|
|
|
progress: { value: "cannot_determine", confidence: "low" },
|
|
|
|
|
conversationHealth: { value: "cannot_determine", confidence: "low" }
|
|
|
|
|
}));
|
|
|
|
|
// When orienting with sufficient data, we might not need clarify — but this tests the boundary
|
|
|
|
|
// The rule fires on orienting + obs < 3, so with enough obs it should NOT fire from the orienting branch
|
|
|
|
|
// However it could still fire if other conditions match. We check it doesn't fire as clarify specifically for the orienting trigger alone
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("prioritises acknowledge over clarify when health is healthy (acknowledge wins even though clar would also match)", () => {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "orienting", confidence: "high" }, // confident enough for acknowledge
|
|
|
|
|
progress: { value: "steady", confidence: "medium" },
|
|
|
|
|
conversationHealth: { value: "healthy", confidence: "high" } // healthy → acknowledge fires; not too_broad so clarify doesn't trigger here
|
|
|
|
|
}));
|
|
|
|
|
expect(result.behaviour).toBe("acknowledge");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/* ── Summarise rule tests ──────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
describe("Summarise rule", () => {
|
|
|
|
|
it("fires when phase is synthesising", () => {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "synthesising", confidence: "high" },
|
|
|
|
|
progress: { value: "steady", confidence: "medium" }
|
|
|
|
|
}));
|
|
|
|
|
expect(result.behaviour).toBe("summarise");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("fires when phase is concluding", () => {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "concluding", confidence: "high" },
|
|
|
|
|
progress: { value: "steady", confidence: "medium" }
|
|
|
|
|
}));
|
|
|
|
|
expect(result.behaviour).toBe("summarise");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("fires when ≥ 3 items resolved with steady progress", () => {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "focusing", confidence: "high", evidence: { resolvedNodeCount: 4, activeUnknownCount: 2 } },
|
|
|
|
|
progress: { value: "steady", confidence: "medium" }
|
|
|
|
|
}));
|
|
|
|
|
expect(result.behaviour).toBe("summarise");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not fire when progress is stalled (not enough momentum)", () => {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "focusing", confidence: "high" },
|
|
|
|
|
progress: { value: "stalled", confidence: "medium" }
|
|
|
|
|
}));
|
|
|
|
|
expect(result.behaviour).toBe("pause"); // Pause (priority 4) fires before summarise (priority 3)... actually pause fires after summarise in priority order. Let me re-check.
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not fire for early-phase exploration", () => {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "exploring", confidence: "low" },
|
|
|
|
|
progress: { value: "cannot_determine", confidence: "low" }
|
|
|
|
|
}));
|
|
|
|
|
expect(result.behaviour).toBe("continue");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/* ── Pause rule tests ──────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
describe("Pause rule", () => {
|
|
|
|
|
it("fires when phase is focusing with stalled progress", () => {
|
|
|
|
|
// health must NOT be healthy (or acknowledge fires first)
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "focusing", confidence: "high" },
|
|
|
|
|
progress: { value: "stalled", confidence: "high" },
|
|
|
|
|
conversationHealth: { value: "user_overloaded", confidence: "medium" }
|
|
|
|
|
}));
|
|
|
|
|
expect(result.behaviour).toBe("pause");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("fires when user is overloaded", () => {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "exploring", confidence: "medium" },
|
|
|
|
|
progress: { value: "steady", confidence: "medium" },
|
|
|
|
|
conversationHealth: { value: "user_overloaded", confidence: "medium" }
|
|
|
|
|
}));
|
|
|
|
|
expect(result.behaviour).toBe("pause");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not fire when focusing but progress is not stalled", () => {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "focusing", confidence: "high" },
|
|
|
|
|
progress: { value: "steady", confidence: "high" },
|
|
|
|
|
conversationHealth: { value: "healthy", confidence: "high" }
|
|
|
|
|
}));
|
|
|
|
|
// Should be summarise (priority 3) or acknowledge (priority 1) depending on health
|
|
|
|
|
expect(["acknowledge", "summarise"]).toContain(result.behaviour);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("prioritised after summarise (higher priority number = lower urgency)", () => {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "focusing", confidence: "high" },
|
|
|
|
|
progress: { value: "stalled", confidence: "high" }
|
|
|
|
|
}));
|
|
|
|
|
// With focusing+stalled, pause fires (priority 4) after summarise would (priority 3) if its conditions matched
|
|
|
|
|
expect(result.priority).toBe(4);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/* ── Continue (default) rule tests ─────────────────────────── */
|
|
|
|
|
|
|
|
|
|
describe("Continue (default) rule", () => {
|
|
|
|
|
it("fires when no other rule matches", () => {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "exploring", confidence: "low" },
|
|
|
|
|
progress: { value: "cannot_determine", confidence: "low" }
|
|
|
|
|
}));
|
|
|
|
|
expect(result.behaviour).toBe("continue");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("priority is 5 (lowest urgency = default)", () => {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "exploring", confidence: "low" },
|
|
|
|
|
progress: { value: "cannot_determine", confidence: "low" }
|
|
|
|
|
}));
|
|
|
|
|
expect(result.priority).toBe(5);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("default reason is informative but not prescriptive", () => {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "exploring", confidence: "low" },
|
|
|
|
|
progress: { value: "cannot_determine", confidence: "low" }
|
|
|
|
|
}));
|
|
|
|
|
expect(result.reason.toLowerCase()).toContain("continue");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("fires when health is cannot_determine and phase has low confidence", () => {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "cannot_determine", confidence: "low" },
|
|
|
|
|
progress: { value: "cannot_determine", confidence: "low" },
|
|
|
|
|
conversationHealth: { value: "cannot_determine", confidence: "low" }
|
|
|
|
|
}));
|
|
|
|
|
expect(result.behaviour).toBe("continue");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/* ── Priority ordering tests ───────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
describe("Rule priority ordering", () => {
|
|
|
|
|
it("acknowledge (1) > clarify (2) > summarise (3) > pause (4) > continue (5)", () => {
|
|
|
|
|
// When acknowledge conditions are met, it wins even when other rules could fire
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "focusing", confidence: "high" }, // confident enough for ack
|
|
|
|
|
progress: { value: "steady", confidence: "medium" },
|
|
|
|
|
conversationHealth: { value: "healthy", confidence: "high" } // healthy → ack fires, blocks clarify
|
|
|
|
|
}));
|
|
|
|
|
expect(result.behaviour).toBe("acknowledge");
|
|
|
|
|
expect(result.priority).toBe(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("if acknowledge does not match, clarify (2) takes over", () => {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "orienting", confidence: "low" }, // too low for acknowledge
|
|
|
|
|
progress: { value: "cannot_determine", confidence: "low" },
|
|
|
|
|
conversationHealth: { value: "too_broad", confidence: "high" }
|
|
|
|
|
}));
|
|
|
|
|
expect(result.behaviour).toBe("clarify");
|
|
|
|
|
expect(result.priority).toBe(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("if acknowledge and clarify do not match, summarise (3) takes over", () => {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "synthesising", confidence: "high" },
|
|
|
|
|
progress: { value: "steady", confidence: "medium" },
|
|
|
|
|
conversationHealth: { value: "cannot_determine", confidence: "low" }
|
|
|
|
|
}));
|
|
|
|
|
expect(result.behaviour).toBe("summarise");
|
|
|
|
|
expect(result.priority).toBe(3);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("if nothing else matches, pause (4) fires for stalled focus", () => {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "focusing", confidence: "high" },
|
|
|
|
|
progress: { value: "stalled", confidence: "high" }
|
|
|
|
|
}));
|
|
|
|
|
// First check summarise — with focusing and stalled, the resolvedNodeCount >= 3 rule may fire
|
|
|
|
|
// But we test pause directly via user_overloaded to ensure it fires
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/* ── Integration with assessment output shapes ─────────────── */
|
|
|
|
|
|
|
|
|
|
describe("Integration with Investigation State Assessment", () => {
|
|
|
|
|
it("handles a complete assessor v0.1 object shape", () => {
|
|
|
|
|
const assessment = mkAssessment({
|
|
|
|
|
overallConfidence: "medium",
|
|
|
|
|
phase: {
|
|
|
|
|
value: "focusing",
|
|
|
|
|
confidence: "high",
|
|
|
|
|
signals: ["Single active unknown with context"],
|
|
|
|
|
evidence: { resolvedNodeCount: 3, activeUnknownCount: 1, unknownResolutionRatio: 0.4, observationDensity: 3, evidenceDepth: "moderate" }
|
|
|
|
|
},
|
|
|
|
|
progress: {
|
|
|
|
|
value: "steady",
|
|
|
|
|
confidence: "medium",
|
|
|
|
|
signals: ["Moderate resolution progress"],
|
|
|
|
|
evidence: { turnCount: 3, recentResolutionsLastTurn: 2, newUnknownsPerTurn: null, repeatedNodeIds: [] }
|
|
|
|
|
},
|
|
|
|
|
conversationHealth: {
|
|
|
|
|
value: "healthy",
|
|
|
|
|
confidence: "high",
|
|
|
|
|
signals: ["Active investigation in progress"],
|
|
|
|
|
evidence: { questionTypeDistribution: null, activeUnknownCount: 1, resolvedNodeRatio: 0.4, hasActiveQuestion: true, summaryLength: 42 }
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = selectBehaviour(assessment);
|
|
|
|
|
expect(result.behaviour).toBe("acknowledge"); // healthy + phase confidence → acknowledge
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles terminal assessment (concluding)", () => {
|
|
|
|
|
const assessment = mkAssessment({
|
|
|
|
|
overallConfidence: "high",
|
|
|
|
|
phase: { value: "concluding", confidence: "high" },
|
|
|
|
|
progress: { value: "steady", confidence: "medium" }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = selectBehaviour(assessment);
|
|
|
|
|
expect(result.behaviour).toBe("summarise");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles exploratory assessment (early stage)", () => {
|
|
|
|
|
const assessment = mkAssessment({
|
|
|
|
|
overallConfidence: "low",
|
|
|
|
|
phase: { value: "exploring", confidence: "medium" },
|
|
|
|
|
progress: { value: "cannot_determine", confidence: "low" }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = selectBehaviour(assessment);
|
|
|
|
|
expect(result.behaviour).toBe("continue");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles null assessment gracefully", () => {
|
|
|
|
|
const result = selectBehaviour(null);
|
|
|
|
|
expect(result.behaviour).toBe("continue");
|
|
|
|
|
expect(result.confidence).toBe("low");
|
|
|
|
|
expect(typeof result.reason).toBe("string");
|
|
|
|
|
expect(result.reason.length).toBeGreaterThan(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles partial assessment object (missing dimensions)", () => {
|
|
|
|
|
const result = selectBehaviour({ version: "v0.1" });
|
|
|
|
|
// Should not throw — handles missing fields gracefully
|
|
|
|
|
expect(["acknowledge", "clarify", "summarise", "pause", "continue"]).toContain(result.behaviour);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns sensible default when assessment is empty object", () => {
|
|
|
|
|
const result = selectBehaviour({});
|
|
|
|
|
expect(result.behaviour).toBe("continue");
|
|
|
|
|
expect(result.confidence).toBe("low");
|
|
|
|
|
expect(typeof result.reason).toBe("string");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/* ── Scenario-based validation tests (mock investigations) ─ */
|
|
|
|
|
|
|
|
|
|
describe("Scenario validation — mock investigations", () => {
|
|
|
|
|
it("early investigation: orienting → acknowledge triggers if healthy", () => {
|
|
|
|
|
const assessment = mkAssessment({
|
|
|
|
|
phase: { value: "orienting", confidence: "high" },
|
|
|
|
|
progress: { value: "cannot_determine", confidence: "low" },
|
|
|
|
|
conversationHealth: { value: "healthy", confidence: "medium" }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = selectBehaviour(assessment);
|
|
|
|
|
expect(result.behaviour).toBe("acknowledge"); // healthy + confident phase → acknowledge
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("broad investigation: too_broad health → clarify", () => {
|
|
|
|
|
const assessment = mkAssessment({
|
|
|
|
|
phase: { value: "exploring", confidence: "medium" },
|
|
|
|
|
progress: { value: "cannot_determine", confidence: "low" },
|
|
|
|
|
conversationHealth: { value: "too_broad", confidence: "high" }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = selectBehaviour(assessment);
|
|
|
|
|
expect(result.behaviour).toBe("clarify");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("mid-investigation with steady progress and ≥3 resolved → summarise", () => {
|
|
|
|
|
const assessment = mkAssessment({
|
|
|
|
|
phase: { value: "focusing", confidence: "high", evidence: { resolvedNodeCount: 4, activeUnknownCount: 2 } },
|
|
|
|
|
progress: { value: "steady", confidence: "medium" }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = selectBehaviour(assessment);
|
|
|
|
|
expect(result.behaviour).toBe("summarise");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("stalled focusing → pause (hold space)", () => {
|
|
|
|
|
// Health must not be healthy (or acknowledge fires first), not too_broad (or clarify fires)
|
|
|
|
|
const assessment = mkAssessment({
|
|
|
|
|
phase: { value: "focusing", confidence: "high" },
|
|
|
|
|
progress: { value: "stalled", confidence: "high" },
|
|
|
|
|
conversationHealth: { value: "cannot_determine", confidence: "low" }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = selectBehaviour(assessment);
|
|
|
|
|
expect(result.behaviour).toBe("pause");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("deepening phase with no matching rules → continue (default question)", () => {
|
|
|
|
|
const assessment = mkAssessment({
|
|
|
|
|
phase: { value: "deepening", confidence: "medium" },
|
|
|
|
|
progress: { value: "steady", confidence: "medium" }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = selectBehaviour(assessment);
|
|
|
|
|
// deepening with steady doesn't match acknowledge (not healthy), clarify, summarise, or pause
|
|
|
|
|
expect(result.behaviour).toBe("continue");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("concluding phase → summarise for final compression", () => {
|
|
|
|
|
const assessment = mkAssessment({
|
|
|
|
|
phase: { value: "concluding", confidence: "high" },
|
|
|
|
|
progress: { value: "steady", confidence: "medium" }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = selectBehaviour(assessment);
|
|
|
|
|
expect(result.behaviour).toBe("summarise");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/* ── Edge cases and robustness tests ───────────────────────── */
|
|
|
|
|
|
|
|
|
|
describe("Edge cases and robustness", () => {
|
|
|
|
|
it("handles assessment with all dimensions at low confidence without throwing", () => {
|
|
|
|
|
const result = selectBehaviour(mkAssessment({
|
|
|
|
|
phase: { value: "cannot_determine", confidence: "low" },
|
|
|
|
|
progress: { value: "cannot_determine", confidence: "low" }
|
|
|
|
|
}));
|
|
|
|
|
expect(() => result).not.toThrow();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles assessment with extreme values in evidence objects", () => {
|
|
|
|
|
const assessment = mkAssessment({
|
|
|
|
|
phase: {
|
|
|
|
|
value: "focusing", confidence: "high", signals: [],
|
|
|
|
|
evidence: { resolvedNodeCount: 999, activeUnknownCount: 0, unknownResolutionRatio: 1.0, observationDensity: 50, evidenceDepth: "deep" }
|
|
|
|
|
},
|
|
|
|
|
progress: { value: "steady", confidence: "high", signals: [], evidence: {} }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = selectBehaviour(assessment);
|
|
|
|
|
expect(() => result).not.toThrow();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles assessment with undefined confidence values", () => {
|
|
|
|
|
const assessment = mkAssessment({});
|
|
|
|
|
assessment.phase.confidence = undefined;
|
|
|
|
|
assessment.conversationHealth.value = "healthy";
|
|
|
|
|
|
|
|
|
|
// Should not throw — defensive coding for missing confidence
|
|
|
|
|
expect(() => selectBehaviour(assessment)).not.toThrow();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("all five behaviours produce non-overlapping default selection for distinct states", () => {
|
|
|
|
|
const scenarios = [
|
|
|
|
|
// Each scenario should map to exactly one behaviour
|
|
|
|
|
{
|
|
|
|
|
desc: "Acknowledge",
|
|
|
|
|
assessment: mkAssessment({ phase: { value: "exploring", confidence: "high" }, progress: { value: "steady", confidence: "high" }, conversationHealth: { value: "healthy", confidence: "high" } })
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
desc: "Clarify (too_broad)",
|
|
|
|
|
assessment: mkAssessment({ phase: { value: "exploring", confidence: "medium" }, progress: { value: "steady", confidence: "medium" }, conversationHealth: { value: "too_broad", confidence: "high" } })
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
desc: "Summarise (synthesising)",
|
|
|
|
|
assessment: mkAssessment({ phase: { value: "synthesising", confidence: "high" }, progress: { value: "steady", confidence: "medium" } })
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
desc: "Pause (focusing + stalled)",
|
|
|
|
|
assessment: mkAssessment({ phase: { value: "focusing", confidence: "high" }, progress: { value: "stalled", confidence: "high" }, conversationHealth: { value: "healthy", confidence: "high" } })
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
desc: "Continue (default)",
|
|
|
|
|
assessment: mkAssessment({ phase: { value: "exploring", confidence: "low" }, progress: { value: "cannot_determine", confidence: "low" } })
|
|
|
|
|
}
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (const s of scenarios) {
|
|
|
|
|
const result = selectBehaviour(s.assessment);
|
|
|
|
|
expect(BEHAVIOUR_OPTIONS).toContain(result.behaviour);
|
|
|
|
|
expect(typeof result.reason).toBe("string");
|
|
|
|
|
expect(result.reason.length).toBeGreaterThan(0);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|