import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync, statSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
// ESM equivalents of CommonJS __filename / __dirname — package.json carries
// "type": "module" so .js files compile to ESM and the CommonJS globals are
// not defined at runtime.
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Regression coverage — memory-classify Haiku timeout cliff.
//
// The 60_000 ms default in `oauth-llm` fits short admin classifiers
// (commitment, query — <2K input, <500 output) but is hostile
// to document classification: a 15K-char input at 8K maxTokens has been
// observed at 56s p99 on Haiku 4.5, which is on the cliff and tipped over
// to a fallback timeout in a real ingest. The fix is a per-call override
// at the long-output caller, NOT a wrapper-default change — short
// classifiers must keep 60s.
//
// These tests assert the contract by reading source. The brief's first
// verification step is literally `grep -n "timeoutMs" llm-classifier.ts
// returns 180_000`; this file formalises that grep so a future refactor
// that drops the override re-fails the test on `main` pre-fix.
// Tests read source files via grep, so paths must resolve to source whether
// the test runs from `src/lib/__tests__/` (tsx) or `dist/lib/__tests__/`
// (compiled). `__dirname` points to one or the other; walking up three levels
// reaches the memory MCP package root in either case.
const PACKAGE_ROOT = resolve(__dirname, "..", "..", "..");
const CLASSIFIER_PATH = resolve(PACKAGE_ROOT, "src/lib/llm-classifier.ts");
const OAUTH_LLM_PATH = resolve(PACKAGE_ROOT, "../../../lib/oauth-llm/src/index.ts");
const SKILL_MD_PATH = resolve(PACKAGE_ROOT, "../skills/document-ingest/SKILL.md");
const FIXTURE_PATH = resolve(PACKAGE_ROOT, "../../../../.docs/maxy_investor_thesis.md");
function readClassifier() {
    return readFileSync(CLASSIFIER_PATH, "utf-8");
}
test("memory-classify passes timeoutMs: 180_000 to callOauthLlm", () => {
    const source = readClassifier();
    const callMatch = source.match(/callOauthLlm\(\{[\s\S]*?\}\)/);
    assert.ok(callMatch, "expected to find callOauthLlm({...}) call site");
    assert.match(callMatch[0], /timeoutMs:\s*180_000/, "classifier MUST pass timeoutMs: 180_000 explicitly — the wrapper's 60s default is too tight for 8K-maxToken Haiku calls on >=10K-char inputs");
});
test("oauth-llm wrapper default stays 60_000 — short classifiers keep the safety net", () => {
    const source = readFileSync(OAUTH_LLM_PATH, "utf-8");
    assert.match(source, /timeoutMs\s*=\s*60_000/, "wrapper default must remain 60_000; long-output callers override per-call (doctrine)");
});
test("[memory-classify] calling haiku log includes timeoutMs", () => {
    const source = readClassifier();
    assert.match(source, /\[memory-classify\][^\n]*calling haiku[^\n]*timeoutMs=/, "calling-haiku log line must surface the timeout budget so size→latency is grep-able from server.log");
});
test("[memory-classify] haiku ok log includes elapsedMs", () => {
    const source = readClassifier();
    assert.match(source, /\[memory-classify\][^\n]*haiku ok[^\n]*elapsedMs=/, "haiku-ok log line must surface elapsed wall-clock ms so size→latency curve is grep-able");
});
test("document-ingest SKILL.md Step 1 carries the chars→latency estimate", () => {
    const source = readFileSync(SKILL_MD_PATH, "utf-8");
    // The estimate is a chars→seconds table the agent reads and emits to chat
    // after extract returns. The >525K chunked-path band sits on top of
    // the original four; the test now asserts the full ladder up through
    // chunked.
    assert.match(source, /<5K[\s\S]*?5K.{0,10}10K[\s\S]*?10K.{0,10}20K[\s\S]*?20K.{0,10}525K[\s\S]*?>525K/, "SKILL.md Step 1 must declare the chars→latency estimate including the chunked-path band so the operator narrative names the expected wait window");
});
// ---------------------------------------------------------------------------
// classifier prompt must broaden documentEdges beyond contracts.
//
// earlier, the prompt at line ~254 said: "Currently used for contracts:
// when a Parties section is detected, emit one or more { type: 'PARTY', … }
// entries." That gating made every multi-party document (meetings, calls,
// emails, voice-notes) ingest as an island anchored to one node, with no
// KD-level edge to the Persons/Organizations involved. The fix expands the
// prompt to a per-document-shape table naming all five edge types the writer
// now accepts (PARTY/PARTICIPANT/FROM/TO/CC/SPEAKER) so the classifier emits
// them on the right shape of document.
// ---------------------------------------------------------------------------
test("classifier prompt no longer says 'Currently used for contracts'", () => {
    const source = readClassifier();
    assert.doesNotMatch(source, /Currently used for contracts/, "documentEdges instruction must not gate to contracts only — meetings/calls/emails/voice-notes need KD-level edges too");
});
test("classifier prompt names every supported KD-level edge type", () => {
    const source = readClassifier();
    for (const edgeType of ["PARTY", "PARTICIPANT", "FROM", "TO", "CC", "SPEAKER"]) {
        assert.match(source, new RegExp(`'${edgeType}'`), `documentEdges instruction must name '${edgeType}' so the classifier emits it on the matching document shape`);
    }
});
test("classifier prompt names every supported document shape", () => {
    const source = readClassifier();
    for (const shape of ["contract", "meeting", "email", "voice-note"]) {
        assert.match(source, new RegExp(shape, "i"), `documentEdges instruction must name '${shape}' so the classifier picks the right edge type per shape`);
    }
});
// ---------------------------------------------------------------------------
// chat-mode classifier (mode: 'chat').
//
// Adds `Conversation` to the section-kind enumeration and routes to a
// dedicated chat-mode system prompt that restricts output to a single kind,
// drops anchor/related/documentEdges/orphan logic, and instructs Haiku to
// preserve `[ts] Sender:` prefixes in chunk bodies for downstream provenance
// recovery. These tests assert the contract by reading source — same pattern
// as the grep-the-prompt tests above.
// ---------------------------------------------------------------------------
test("classifier exports a `Conversation` section kind", () => {
    const source = readClassifier();
    // Asserted via the STRUCTURAL_SECTION_KINDS array — Conversation joins the
    // closed enumeration so the validator accepts it.
    assert.match(source, /STRUCTURAL_SECTION_KINDS[\s\S]{0,400}["']Conversation["']/, "Conversation must be a member of STRUCTURAL_SECTION_KINDS so the validator accepts it");
});
test("ClassifyParams accepts an optional `mode: 'document' | 'chat'`", () => {
    const source = readClassifier();
    assert.match(source, /mode\??:\s*["']document["']\s*\|\s*["']chat["']/, "ClassifyParams must declare `mode?: 'document' | 'chat'` so callers can opt into chat-mode chunking");
});
test("chat-mode prompt restricts output to the `Conversation` kind", () => {
    const source = readClassifier();
    const chatPromptMatch = source.match(/CHAT_SYSTEM_PROMPT[\s\S]*?\.join\("\\n"\);/);
    assert.ok(chatPromptMatch, "expected to find a CHAT_SYSTEM_PROMPT array literal");
    assert.match(chatPromptMatch[0], /['"]Conversation['"]/, "chat-mode prompt must restrict output to `kind: 'Conversation'`");
});
test("chat-mode prompt instructs Haiku to preserve [ts] Sender: prefixes verbatim in chunk bodies", () => {
    const source = readClassifier();
    const chatPromptMatch = source.match(/CHAT_SYSTEM_PROMPT[\s\S]*?\.join\("\\n"\);/);
    assert.ok(chatPromptMatch);
    assert.match(chatPromptMatch[0], /verbatim/i, "chat-mode prompt must use the word 'verbatim' so Haiku preserves the prefixes — downstream provenance recovery depends on byte equality");
    assert.match(chatPromptMatch[0], /prefix/i, "chat-mode prompt must explicitly name the `[ts] Sender:` prefix preservation rule");
});
test("chat-mode prompt forbids anchorEdge / related / documentEdges proposals", () => {
    const source = readClassifier();
    const chatPromptMatch = source.match(/CHAT_SYSTEM_PROMPT[\s\S]*?\.join\("\\n"\);/);
    assert.ok(chatPromptMatch);
    assert.match(chatPromptMatch[0], /anchorEdge[\s\S]*?(?:MUST be null|null)/, "chat-mode prompt must require anchorEdge: null on every chunk");
});
test("chat-mode requires firstMessageAt + lastMessageAt + participantNames + messageCount on each chunk", () => {
    const source = readClassifier();
    const chatPromptMatch = source.match(/CHAT_SYSTEM_PROMPT[\s\S]*?\.join\("\\n"\);/);
    assert.ok(chatPromptMatch);
    for (const prop of ["firstMessageAt", "lastMessageAt", "participantNames", "messageCount", "summary", "keywords"]) {
        assert.match(chatPromptMatch[0], new RegExp(prop), `chat-mode prompt must instruct Haiku to emit the '${prop}' chunk property`);
    }
});
test("classifyDocument logs `mode=` for grep parity", () => {
    const source = readClassifier();
    assert.match(source, /\[memory-classify\][^\n]*calling haiku[^\n]*mode=/, "calling-haiku log line must include `mode=<document|chat>` for grep parity");
});
// ---------------------------------------------------------------------------
// classifier + ranker structured tool-use migration.
//
// The classifier and ranker now force Haiku to emit `tool_use.input` matching
// a typed `input_schema`. The migration deletes `extractJson` (used to strip
// markdown fences from a free-form text response) and the malformed-JSON
// fallback path entirely — Anthropic enforces the schema at the API boundary,
// so structural failures (missing fields, wrong types, truncation mid-tool-input)
// surface as fallback responses or as the existing missing-offsets loud-fail.
//
// These tests grep source so a future refactor that reverts to free-form text
// re-fails the test suite on `main` pre-fix.
// ---------------------------------------------------------------------------
const RANKER_PATH = resolve(PACKAGE_ROOT, "src/lib/llm-ranker.ts");
function readRanker() {
    return readFileSync(RANKER_PATH, "utf-8");
}
test("classifier defines a ClassifierOutputTool with input_schema", () => {
    const source = readClassifier();
    assert.match(source, /ClassifierOutputTool[\s\S]{0,200}name:\s*["']emit_classifier_output["']/, "classifier must export a ClassifierOutputTool whose name is 'emit_classifier_output'");
    assert.match(source, /ClassifierOutputTool[\s\S]*?input_schema/, "ClassifierOutputTool must declare an input_schema");
});
test("classifier call site forces tool selection", () => {
    const source = readClassifier();
    const callMatch = source.match(/callOauthLlm\(\{[\s\S]*?\}\)/);
    assert.ok(callMatch, "expected to find callOauthLlm({...}) call site");
    assert.match(callMatch[0], /tools:\s*\[ClassifierOutputTool\]/, "classifier MUST pass `tools: [ClassifierOutputTool]` so Haiku emits structured output");
    assert.match(callMatch[0], /toolChoiceName:\s*ClassifierOutputTool\.name/, "classifier MUST force the tool by name — Anthropic only validates the schema when forced");
});
test("classifier `kind` schema is enforced as an enum (closed enumeration)", () => {
    const source = readClassifier();
    // The schema must declare `kind` with an `enum` whose membership is sourced
    // from ALL_SECTION_KINDS. We check the structural shape rather than the
    // exact list — the list is exported and tested elsewhere.
    assert.match(source, /kind:\s*\{\s*type:\s*["']string["'],\s*enum:\s*ALL_SECTION_KINDS_LIST/, "ClassifierOutputTool.input_schema must enforce `kind` as an enum sourced from ALL_SECTION_KINDS — schema-level enforcement is what makes hallucinated kinds impossible");
});
test("classifier no longer references extractJson (dead code removed)", () => {
    const source = readClassifier();
    assert.doesNotMatch(source, /extractJson/, "extractJson is unreachable on the structured-output path and was deleted; reintroducing it is the regression");
});
test("classifier no longer collapses unknown kinds to Other (unreachable on structured path)", () => {
    const source = readClassifier();
    // `isKnownKind ? rawKind : SECTION_KIND_OTHER` is the fingerprint of an
    // unknown-kind collapse. With a strict schema enum on `kind`, the
    // collapse is unreachable; reintroducing the branch weakens the
    // schema-as-enforcement contract.
    assert.doesNotMatch(source, /isKnownKind/, "the `isKnownKind` collapse is dead code on the structured path; reintroducing it weakens the schema-as-enforcement contract");
});
test("classifier emits `structured-output ok` log line per call", () => {
    const source = readClassifier();
    assert.match(source, /\[memory-classify\][^\n]*structured-output ok[^\n]*stop-reason=/, "classifier must log `structured-output ok stop-reason=...` per call so observability names the structured path");
});
test("ranker defines a RankerOutputTool with input_schema", () => {
    const source = readRanker();
    assert.match(source, /RankerOutputTool[\s\S]{0,200}name:\s*["']emit_ranker_output["']/, "ranker must export a RankerOutputTool whose name is 'emit_ranker_output'");
    assert.match(source, /RankerOutputTool[\s\S]*?input_schema/, "RankerOutputTool must declare an input_schema");
});
test("ranker call site forces tool selection", () => {
    const source = readRanker();
    const callMatch = source.match(/callOauthLlm\(\{[\s\S]*?\}\)/);
    assert.ok(callMatch, "expected to find callOauthLlm({...}) call site");
    assert.match(callMatch[0], /tools:\s*\[RankerOutputTool\]/, "ranker MUST pass `tools: [RankerOutputTool]`");
    assert.match(callMatch[0], /toolChoiceName:\s*RankerOutputTool\.name/, "ranker MUST force the tool by name");
});
test("ranker no longer references extractJson", () => {
    const source = readRanker();
    assert.doesNotMatch(source, /extractJson/, "extractJson is unreachable on the structured-output path and was deleted");
});
test("ranker no longer parses JSON.parse on response text", () => {
    const source = readRanker();
    assert.doesNotMatch(source, /JSON\.parse\(jsonText\)/, "structured tool-use returns a typed object — JSON.parse on response text is dead code");
});
test("ranker emits `structured-output ok` log line per call", () => {
    const source = readRanker();
    assert.match(source, /\[memory-rank\][^\n]*structured-output ok[^\n]*stop-reason=/, "ranker must log `structured-output ok stop-reason=...` per call");
});
test("fixture is still >=10K chars (regression anchor)", () => {
    // The fixture is named in the brief so the same evidence shape can be
    // re-run on demand. If it ever shrinks below 10K, this test fails loud
    // — that is the signal to either replace the fixture or update the
    // brief, not to silently lose coverage of the 10K–20K band.
    const size = statSync(FIXTURE_PATH).size;
    assert.ok(size >= 10_000, `fixture ${FIXTURE_PATH} must be >=10K chars (was ${size}) — covers the 10–20K band where the 60s default tipped over in production`);
});
//# sourceMappingURL=llm-classifier.test.js.map