diff --git a/docs/ARCH/11 - architecture_turnaround/14 - semantic_dialog_authority_recovery_plan_2026-04-19.md b/docs/ARCH/11 - architecture_turnaround/14 - semantic_dialog_authority_recovery_plan_2026-04-19.md index aac2426..410c079 100644 --- a/docs/ARCH/11 - architecture_turnaround/14 - semantic_dialog_authority_recovery_plan_2026-04-19.md +++ b/docs/ARCH/11 - architecture_turnaround/14 - semantic_dialog_authority_recovery_plan_2026-04-19.md @@ -970,6 +970,39 @@ Validation: - `npm test -- assistantMcpDiscoveryResponseCandidate.test.ts assistantMcpDiscoveryRuntimeEntryPoint.test.ts assistantMcpDiscoveryDebugAttachment.test.ts assistantMcpDiscoveryAnswerAdapter.test.ts assistantMcpDiscoveryPilotExecutor.test.ts` passed 17/17; - `npm run build` passed. +## Progress Update - 2026-04-20 MCP Discovery Response Policy Gate + +The thirteenth implementation slice of Big Block 5 added the first explicit guarded answer-replacement policy: + +- `assistantMcpDiscoveryResponsePolicy.ts` +- `assistantMcpDiscoveryResponsePolicy.test.ts` +- living-chat runtime integration for unsupported current-turn meaning boundaries. + +This is the first slice where MCP discovery can affect the user-facing answer, but only through a narrow policy gate. + +The policy can apply a discovery candidate only when all of the following are true: + +- the current living-chat branch is `unsupported_current_turn_meaning_boundary` or its deterministic boundary reply; +- the runtime meta contains a valid `assistant_mcp_discovery_runtime_entry_point_v1` contract; +- the candidate status is one of `ready_for_guarded_use`, `checked_sources_only_candidate`, or `clarification_candidate`; +- `eligible_for_future_hot_runtime=true`; +- the candidate has non-empty user-facing text; +- the text does not expose internal primitive/query/runtime mechanics. + +When the gate does not pass, the old honest boundary answer is preserved. + +The living-chat debug surface now includes: + +- `mcp_discovery_response_policy_v1`; +- `mcp_discovery_response_candidate_v1`; +- `mcp_discovery_response_applied`; +- the standard MCP discovery entry-point attachment fields on chat-path output. + +Validation: + +- `npm test -- assistantMcpDiscoveryResponsePolicy.test.ts assistantLivingChatRuntimeAdapter.test.ts assistantMcpDiscoveryResponseCandidate.test.ts assistantMcpDiscoveryDebugAttachment.test.ts` passed 19/19; +- `npm run build` passed. + ## Execution Rule Do not implement this plan as: diff --git a/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js index c30c9c8..ed15518 100644 --- a/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js @@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.runAssistantLivingChatRuntime = runAssistantLivingChatRuntime; const assistantMemoryRecapPolicy_1 = require("./assistantMemoryRecapPolicy"); const assistantContinuityPolicy_1 = require("./assistantContinuityPolicy"); +const assistantMcpDiscoveryDebugAttachment_1 = require("./assistantMcpDiscoveryDebugAttachment"); +const assistantMcpDiscoveryResponsePolicy_1 = require("./assistantMcpDiscoveryResponsePolicy"); function hasPriorAssistantTurn(items) { if (!Array.isArray(items)) { return false; @@ -228,6 +230,17 @@ async function runAssistantLivingChatRuntime(input) { debug: null }; } + const mcpDiscoveryResponsePolicy = (0, assistantMcpDiscoveryResponsePolicy_1.applyAssistantMcpDiscoveryResponsePolicy)({ + currentReply: chatText, + currentReplySource: livingChatSource, + livingChatSource, + modeDecisionReason: input.modeDecision?.reason ?? null, + addressRuntimeMeta + }); + if (mcpDiscoveryResponsePolicy.applied) { + chatText = mcpDiscoveryResponsePolicy.reply_text; + livingChatSource = mcpDiscoveryResponsePolicy.reply_source; + } const predecomposeContract = addressRuntimeMeta.predecomposeContract && typeof addressRuntimeMeta.predecomposeContract === "object" ? addressRuntimeMeta.predecomposeContract : null; @@ -245,6 +258,9 @@ async function runAssistantLivingChatRuntime(input) { living_router_mode: input.modeDecision?.mode ?? "chat", living_router_reason: input.modeDecision?.reason ?? "living_chat_signal_detected", living_chat_response_source: livingChatSource, + mcp_discovery_response_policy_v1: mcpDiscoveryResponsePolicy, + mcp_discovery_response_candidate_v1: mcpDiscoveryResponsePolicy.candidate, + mcp_discovery_response_applied: mcpDiscoveryResponsePolicy.applied, living_chat_script_guard_applied: livingChatScriptGuardApplied, living_chat_script_guard_reason: livingChatScriptGuardReason, living_chat_grounding_guard_applied: livingChatGroundingGuardApplied, @@ -278,6 +294,6 @@ async function runAssistantLivingChatRuntime(input) { return { handled: true, chatText, - debug + debug: (0, assistantMcpDiscoveryDebugAttachment_1.attachAssistantMcpDiscoveryDebug)(debug, { addressRuntimeMeta }) }; } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js new file mode 100644 index 0000000..3a64e1d --- /dev/null +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js @@ -0,0 +1,122 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION = void 0; +exports.applyAssistantMcpDiscoveryResponsePolicy = applyAssistantMcpDiscoveryResponsePolicy; +const assistantMcpDiscoveryResponseCandidate_1 = require("./assistantMcpDiscoveryResponseCandidate"); +exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION = "assistant_mcp_discovery_response_policy_v1"; +const ALLOWED_CANDIDATE_STATUSES = new Set([ + "ready_for_guarded_use", + "checked_sources_only_candidate", + "clarification_candidate" +]); +function toRecordObject(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value; +} +function toNonEmptyString(value) { + if (value === null || value === undefined) { + return null; + } + const text = String(value).trim(); + return text.length > 0 ? text : null; +} +function normalizeReasonCode(value) { + const normalized = value + .trim() + .replace(/[^\p{L}\p{N}_.:-]+/gu, "_") + .replace(/^_+|_+$/g, "") + .toLowerCase(); + return normalized.length > 0 ? normalized.slice(0, 120) : null; +} +function pushReason(target, value) { + const normalized = normalizeReasonCode(value); + if (normalized && !target.includes(normalized)) { + target.push(normalized); + } +} +function hasInternalMechanics(value) { + const text = value.toLowerCase(); + return (text.includes("query_documents") || + text.includes("query_movements") || + text.includes("primitive") || + text.includes("runtime_") || + text.includes("planner_") || + text.includes("catalog_") || + text.includes("select ")); +} +function isMcpDiscoveryEntryPointContract(value) { + const record = toRecordObject(value); + return (record?.schema_version === "assistant_mcp_discovery_runtime_entry_point_v1" && + record?.policy_owner === "assistantMcpDiscoveryRuntimeEntryPoint"); +} +function resolveEntryPoint(input) { + if (isMcpDiscoveryEntryPointContract(input.entryPoint)) { + return input.entryPoint; + } + const runtimeMetaEntryPoint = input.addressRuntimeMeta?.mcpDiscoveryRuntimeEntryPoint ?? + input.addressRuntimeMeta?.assistantMcpDiscoveryRuntimeEntryPoint ?? + input.addressRuntimeMeta?.assistant_mcp_discovery_entry_point_v1; + return isMcpDiscoveryEntryPointContract(runtimeMetaEntryPoint) ? runtimeMetaEntryPoint : null; +} +function isUnsupportedCurrentTurnBoundary(input) { + return (input.modeDecisionReason === "unsupported_current_turn_meaning_boundary" || + input.livingChatSource === "deterministic_unsupported_current_turn_boundary" || + input.currentReplySource === "deterministic_unsupported_current_turn_boundary"); +} +function applyAssistantMcpDiscoveryResponsePolicy(input) { + const currentReply = String(input.currentReply ?? ""); + const currentReplySource = toNonEmptyString(input.currentReplySource) ?? toNonEmptyString(input.livingChatSource) ?? "unknown"; + const entryPoint = resolveEntryPoint(input); + const candidate = (0, assistantMcpDiscoveryResponseCandidate_1.buildAssistantMcpDiscoveryResponseCandidate)(entryPoint); + const reasonCodes = [...candidate.reason_codes]; + if (!entryPoint) { + pushReason(reasonCodes, "mcp_discovery_response_policy_no_entry_point"); + } + if (!isUnsupportedCurrentTurnBoundary(input)) { + pushReason(reasonCodes, "mcp_discovery_response_policy_not_unsupported_boundary"); + } + if (!ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status)) { + pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_status_not_allowed"); + } + if (!candidate.eligible_for_future_hot_runtime) { + pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_not_eligible"); + } + if (!toNonEmptyString(candidate.reply_text)) { + pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_missing_reply_text"); + } + if (candidate.reply_text && hasInternalMechanics(candidate.reply_text)) { + pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_contains_internal_mechanics"); + } + const canApply = Boolean(entryPoint) && + isUnsupportedCurrentTurnBoundary(input) && + ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) && + candidate.eligible_for_future_hot_runtime && + Boolean(toNonEmptyString(candidate.reply_text)) && + !hasInternalMechanics(String(candidate.reply_text ?? "")); + if (!canApply) { + pushReason(reasonCodes, "mcp_discovery_response_policy_kept_current_reply"); + return { + schema_version: exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryResponsePolicy", + decision: "keep_current_reply", + applied: false, + reply_text: currentReply, + reply_source: currentReplySource, + candidate, + reason_codes: reasonCodes + }; + } + pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_applied"); + return { + schema_version: exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryResponsePolicy", + decision: "apply_candidate", + applied: true, + reply_text: String(candidate.reply_text), + reply_source: "mcp_discovery_response_candidate_guarded", + candidate, + reason_codes: reasonCodes + }; +} diff --git a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts index 9ec70a4..9b7aac4 100644 --- a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts @@ -5,6 +5,8 @@ import { resolveAssistantLivingChatMemoryContext } from "./assistantMemoryRecapPolicy"; import { resolveAssistantOrganizationAuthority } from "./assistantContinuityPolicy"; +import { attachAssistantMcpDiscoveryDebug } from "./assistantMcpDiscoveryDebugAttachment"; +import { applyAssistantMcpDiscoveryResponsePolicy } from "./assistantMcpDiscoveryResponsePolicy"; export interface AssistantLivingChatSessionScopeInput { knownOrganizations?: unknown[]; @@ -305,6 +307,18 @@ export async function runAssistantLivingChatRuntime( }; } + const mcpDiscoveryResponsePolicy = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: chatText, + currentReplySource: livingChatSource, + livingChatSource, + modeDecisionReason: input.modeDecision?.reason ?? null, + addressRuntimeMeta + }); + if (mcpDiscoveryResponsePolicy.applied) { + chatText = mcpDiscoveryResponsePolicy.reply_text; + livingChatSource = mcpDiscoveryResponsePolicy.reply_source; + } + const predecomposeContract = addressRuntimeMeta.predecomposeContract && typeof addressRuntimeMeta.predecomposeContract === "object" ? (addressRuntimeMeta.predecomposeContract as Record) @@ -325,6 +339,9 @@ export async function runAssistantLivingChatRuntime( living_router_mode: input.modeDecision?.mode ?? "chat", living_router_reason: input.modeDecision?.reason ?? "living_chat_signal_detected", living_chat_response_source: livingChatSource, + mcp_discovery_response_policy_v1: mcpDiscoveryResponsePolicy, + mcp_discovery_response_candidate_v1: mcpDiscoveryResponsePolicy.candidate, + mcp_discovery_response_applied: mcpDiscoveryResponsePolicy.applied, living_chat_script_guard_applied: livingChatScriptGuardApplied, living_chat_script_guard_reason: livingChatScriptGuardReason, living_chat_grounding_guard_applied: livingChatGroundingGuardApplied, @@ -359,6 +376,6 @@ export async function runAssistantLivingChatRuntime( return { handled: true, chatText, - debug + debug: attachAssistantMcpDiscoveryDebug(debug, { addressRuntimeMeta }) }; } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts new file mode 100644 index 0000000..b24473a --- /dev/null +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts @@ -0,0 +1,174 @@ +import { + buildAssistantMcpDiscoveryResponseCandidate, + type AssistantMcpDiscoveryResponseCandidateContract, + type AssistantMcpDiscoveryResponseCandidateStatus +} from "./assistantMcpDiscoveryResponseCandidate"; +import type { AssistantMcpDiscoveryRuntimeEntryPointContract } from "./assistantMcpDiscoveryRuntimeEntryPoint"; + +export const ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION = + "assistant_mcp_discovery_response_policy_v1" as const; + +export type AssistantMcpDiscoveryResponsePolicyDecision = "apply_candidate" | "keep_current_reply"; + +export interface ApplyAssistantMcpDiscoveryResponsePolicyInput { + currentReply: string; + currentReplySource?: string | null; + livingChatSource?: string | null; + modeDecisionReason?: string | null; + addressRuntimeMeta?: Record | null; + entryPoint?: unknown; +} + +export interface AssistantMcpDiscoveryResponsePolicyResult { + schema_version: typeof ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION; + policy_owner: "assistantMcpDiscoveryResponsePolicy"; + decision: AssistantMcpDiscoveryResponsePolicyDecision; + applied: boolean; + reply_text: string; + reply_source: string; + candidate: AssistantMcpDiscoveryResponseCandidateContract; + reason_codes: string[]; +} + +const ALLOWED_CANDIDATE_STATUSES = new Set([ + "ready_for_guarded_use", + "checked_sources_only_candidate", + "clarification_candidate" +]); + +function toRecordObject(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function toNonEmptyString(value: unknown): string | null { + if (value === null || value === undefined) { + return null; + } + const text = String(value).trim(); + return text.length > 0 ? text : null; +} + +function normalizeReasonCode(value: string): string | null { + const normalized = value + .trim() + .replace(/[^\p{L}\p{N}_.:-]+/gu, "_") + .replace(/^_+|_+$/g, "") + .toLowerCase(); + return normalized.length > 0 ? normalized.slice(0, 120) : null; +} + +function pushReason(target: string[], value: string): void { + const normalized = normalizeReasonCode(value); + if (normalized && !target.includes(normalized)) { + target.push(normalized); + } +} + +function hasInternalMechanics(value: string): boolean { + const text = value.toLowerCase(); + return ( + text.includes("query_documents") || + text.includes("query_movements") || + text.includes("primitive") || + text.includes("runtime_") || + text.includes("planner_") || + text.includes("catalog_") || + text.includes("select ") + ); +} + +function isMcpDiscoveryEntryPointContract(value: unknown): value is AssistantMcpDiscoveryRuntimeEntryPointContract { + const record = toRecordObject(value); + return ( + record?.schema_version === "assistant_mcp_discovery_runtime_entry_point_v1" && + record?.policy_owner === "assistantMcpDiscoveryRuntimeEntryPoint" + ); +} + +function resolveEntryPoint( + input: ApplyAssistantMcpDiscoveryResponsePolicyInput +): AssistantMcpDiscoveryRuntimeEntryPointContract | null { + if (isMcpDiscoveryEntryPointContract(input.entryPoint)) { + return input.entryPoint; + } + const runtimeMetaEntryPoint = + input.addressRuntimeMeta?.mcpDiscoveryRuntimeEntryPoint ?? + input.addressRuntimeMeta?.assistantMcpDiscoveryRuntimeEntryPoint ?? + input.addressRuntimeMeta?.assistant_mcp_discovery_entry_point_v1; + return isMcpDiscoveryEntryPointContract(runtimeMetaEntryPoint) ? runtimeMetaEntryPoint : null; +} + +function isUnsupportedCurrentTurnBoundary(input: ApplyAssistantMcpDiscoveryResponsePolicyInput): boolean { + return ( + input.modeDecisionReason === "unsupported_current_turn_meaning_boundary" || + input.livingChatSource === "deterministic_unsupported_current_turn_boundary" || + input.currentReplySource === "deterministic_unsupported_current_turn_boundary" + ); +} + +export function applyAssistantMcpDiscoveryResponsePolicy( + input: ApplyAssistantMcpDiscoveryResponsePolicyInput +): AssistantMcpDiscoveryResponsePolicyResult { + const currentReply = String(input.currentReply ?? ""); + const currentReplySource = + toNonEmptyString(input.currentReplySource) ?? toNonEmptyString(input.livingChatSource) ?? "unknown"; + const entryPoint = resolveEntryPoint(input); + const candidate = buildAssistantMcpDiscoveryResponseCandidate(entryPoint); + const reasonCodes = [...candidate.reason_codes]; + + if (!entryPoint) { + pushReason(reasonCodes, "mcp_discovery_response_policy_no_entry_point"); + } + if (!isUnsupportedCurrentTurnBoundary(input)) { + pushReason(reasonCodes, "mcp_discovery_response_policy_not_unsupported_boundary"); + } + if (!ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status)) { + pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_status_not_allowed"); + } + if (!candidate.eligible_for_future_hot_runtime) { + pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_not_eligible"); + } + if (!toNonEmptyString(candidate.reply_text)) { + pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_missing_reply_text"); + } + if (candidate.reply_text && hasInternalMechanics(candidate.reply_text)) { + pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_contains_internal_mechanics"); + } + + const canApply = + Boolean(entryPoint) && + isUnsupportedCurrentTurnBoundary(input) && + ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) && + candidate.eligible_for_future_hot_runtime && + Boolean(toNonEmptyString(candidate.reply_text)) && + !hasInternalMechanics(String(candidate.reply_text ?? "")); + + if (!canApply) { + pushReason(reasonCodes, "mcp_discovery_response_policy_kept_current_reply"); + return { + schema_version: ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryResponsePolicy", + decision: "keep_current_reply", + applied: false, + reply_text: currentReply, + reply_source: currentReplySource, + candidate, + reason_codes: reasonCodes + }; + } + + pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_applied"); + return { + schema_version: ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryResponsePolicy", + decision: "apply_candidate", + applied: true, + reply_text: String(candidate.reply_text), + reply_source: "mcp_discovery_response_candidate_guarded", + candidate, + reason_codes: reasonCodes + }; +} diff --git a/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts index 9560a38..cdce02e 100644 --- a/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts @@ -169,6 +169,66 @@ describe("assistant living chat runtime adapter", () => { expect(executeLlmChat).not.toHaveBeenCalled(); }); + it("replaces unsupported boundary with guarded MCP discovery response when policy allows it", async () => { + const executeLlmChat = vi.fn(async () => "raw-llm"); + const input = buildRuntimeInput({ + userMessage: "how long has svk been active", + modeDecision: { mode: "chat", reason: "unsupported_current_turn_meaning_boundary" }, + addressRuntimeMeta: { + toolGateReason: "unsupported_current_turn_meaning_boundary", + orchestrationContract: { + unsupported_current_turn_meaning_boundary: true, + assistant_turn_meaning: { + unsupported_but_understood_family: "counterparty_lifecycle_or_age", + explicit_entity_candidates: [ + { + type: "counterparty", + value: "SVK" + } + ] + } + }, + mcpDiscoveryRuntimeEntryPoint: { + schema_version: "assistant_mcp_discovery_runtime_entry_point_v1", + policy_owner: "assistantMcpDiscoveryRuntimeEntryPoint", + entry_status: "bridge_executed", + hot_runtime_wired: false, + discovery_attempted: true, + turn_input: { adapter_status: "ready" }, + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "Confirmed scoped answer.", + confirmed_lines: ["Confirmed fact"], + inference_lines: ["Bounded inference"], + unknown_lines: ["Unconfirmed legal fact"], + limitation_lines: ["Limited source window"], + next_step_line: null + } + }, + reason_codes: ["runtime_entry_point_bridge_executed"] + } + }, + executeLlmChat + }); + + const output = await runAssistantLivingChatRuntime(input); + + expect(output.handled).toBe(true); + expect(output.chatText).toContain("Confirmed fact"); + expect(output.chatText).not.toContain("route"); + expect(output.chatText).not.toContain("query_documents"); + expect(output.debug?.living_chat_response_source).toBe("mcp_discovery_response_candidate_guarded"); + expect(output.debug?.mcp_discovery_response_applied).toBe(true); + expect(output.debug?.mcp_discovery_entry_status).toBe("bridge_executed"); + expect(output.debug?.mcp_discovery_attempted).toBe(true); + expect(executeLlmChat).not.toHaveBeenCalled(); + }); + it("adds proactive organization offer on first smalltalk turn when multiple organizations are available", async () => { const resolveDataScopeProbe = vi.fn(async () => ({ status: "resolved", diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts new file mode 100644 index 0000000..a0aa298 --- /dev/null +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { applyAssistantMcpDiscoveryResponsePolicy } from "../src/services/assistantMcpDiscoveryResponsePolicy"; + +function entryPoint(overrides: Record = {}) { + return { + schema_version: "assistant_mcp_discovery_runtime_entry_point_v1", + policy_owner: "assistantMcpDiscoveryRuntimeEntryPoint", + entry_status: "bridge_executed", + hot_runtime_wired: false, + discovery_attempted: true, + turn_input: { adapter_status: "ready" }, + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "Confirmed scoped answer.", + confirmed_lines: ["Confirmed fact"], + inference_lines: ["Bounded inference"], + unknown_lines: ["Unconfirmed fact"], + limitation_lines: ["Limited source window"], + next_step_line: null + } + }, + reason_codes: ["runtime_entry_point_bridge_executed"], + ...overrides + } as any; +} + +describe("assistant MCP discovery response policy", () => { + it("applies a guarded candidate only for unsupported current-turn boundary replies", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: "route is not wired", + currentReplySource: "deterministic_unsupported_current_turn_boundary", + modeDecisionReason: "unsupported_current_turn_meaning_boundary", + addressRuntimeMeta: { + mcpDiscoveryRuntimeEntryPoint: entryPoint() + } + }); + + expect(result.applied).toBe(true); + expect(result.decision).toBe("apply_candidate"); + expect(result.reply_source).toBe("mcp_discovery_response_candidate_guarded"); + expect(result.reply_text).toContain("Confirmed fact"); + expect(result.reply_text).not.toContain("query_documents"); + expect(result.reason_codes).toContain("mcp_discovery_response_policy_candidate_applied"); + }); + + it("keeps the current reply when the turn is not an unsupported boundary", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: "regular chat", + currentReplySource: "llm_chat", + modeDecisionReason: "living_chat_signal_detected", + addressRuntimeMeta: { + mcpDiscoveryRuntimeEntryPoint: entryPoint() + } + }); + + expect(result.applied).toBe(false); + expect(result.decision).toBe("keep_current_reply"); + expect(result.reply_text).toBe("regular chat"); + expect(result.reply_source).toBe("llm_chat"); + expect(result.reason_codes).toContain("mcp_discovery_response_policy_not_unsupported_boundary"); + }); + + it("keeps the current reply when the candidate has no grounded text", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: "route is not wired", + currentReplySource: "deterministic_unsupported_current_turn_boundary", + modeDecisionReason: "unsupported_current_turn_meaning_boundary", + addressRuntimeMeta: { + mcpDiscoveryRuntimeEntryPoint: entryPoint({ + bridge: { + bridge_status: "unsupported", + user_facing_response_allowed: true, + business_fact_answer_allowed: false, + requires_user_clarification: false, + answer_draft: null + } + }) + } + }); + + expect(result.applied).toBe(false); + expect(result.reply_text).toBe("route is not wired"); + expect(result.reason_codes).toContain("mcp_discovery_response_policy_candidate_not_eligible"); + expect(result.reason_codes).toContain("mcp_discovery_response_policy_kept_current_reply"); + }); +});