420 lines
13 KiB
TypeScript
420 lines
13 KiB
TypeScript
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||
|
|
import { readFileSync } from 'fs';
|
||
|
|
import { resolve } from 'path';
|
||
|
|
import { runPipeline, _PIPELINE_INTERNALS } from './pipeline';
|
||
|
|
import * as persistence from './persistence';
|
||
|
|
import type { RawTicketBundle } from './preprocessor';
|
||
|
|
import type Anthropic from '@anthropic-ai/sdk';
|
||
|
|
import type { ITGlueSearchResult } from './itglue-search';
|
||
|
|
|
||
|
|
const FIXTURE = JSON.parse(
|
||
|
|
readFileSync(
|
||
|
|
resolve(__dirname, 'fixtures', 'T20260424.0045.input.json'),
|
||
|
|
'utf8'
|
||
|
|
)
|
||
|
|
) as RawTicketBundle;
|
||
|
|
|
||
|
|
let findExistingSpy: ReturnType<typeof vi.spyOn>;
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
findExistingSpy = vi
|
||
|
|
.spyOn(persistence, 'findExistingAnalysisByContentHash')
|
||
|
|
.mockResolvedValue(null);
|
||
|
|
});
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
findExistingSpy.mockRestore();
|
||
|
|
vi.restoreAllMocks();
|
||
|
|
});
|
||
|
|
|
||
|
|
interface Reply {
|
||
|
|
text: string;
|
||
|
|
usage?: Partial<Anthropic.Usage>;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Sequential mock: each stage is a separate `messages.create` call. Queue the
|
||
|
|
* replies in order: Haiku → Sonnet → (Opus, optional).
|
||
|
|
*/
|
||
|
|
function makeFakeAnthropic(queue: Reply[]): {
|
||
|
|
fake: Anthropic;
|
||
|
|
bodies: any[];
|
||
|
|
} {
|
||
|
|
const bodies: any[] = [];
|
||
|
|
let i = 0;
|
||
|
|
const create = vi.fn(async (body: any) => {
|
||
|
|
bodies.push(body);
|
||
|
|
const next = queue[i++];
|
||
|
|
if (!next) throw new Error('No more queued LLM replies');
|
||
|
|
return {
|
||
|
|
id: `msg_${i}`,
|
||
|
|
type: 'message',
|
||
|
|
role: 'assistant',
|
||
|
|
content: [{ type: 'text', text: next.text }],
|
||
|
|
model: body.model,
|
||
|
|
stop_reason: 'end_turn',
|
||
|
|
stop_sequence: null,
|
||
|
|
usage: {
|
||
|
|
input_tokens: next.usage?.input_tokens ?? 5_000,
|
||
|
|
output_tokens: next.usage?.output_tokens ?? 500,
|
||
|
|
cache_creation_input_tokens: next.usage?.cache_creation_input_tokens ?? 0,
|
||
|
|
cache_read_input_tokens: next.usage?.cache_read_input_tokens ?? 0,
|
||
|
|
},
|
||
|
|
} as Anthropic.Message;
|
||
|
|
});
|
||
|
|
return {
|
||
|
|
fake: { messages: { create } } as unknown as Anthropic,
|
||
|
|
bodies,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const validTriage = (overrides: Record<string, unknown> = {}) =>
|
||
|
|
JSON.stringify({
|
||
|
|
ticket_type: 'service_request',
|
||
|
|
category: 'Vendor Integration',
|
||
|
|
entities: {
|
||
|
|
client_name: 'Seubert and Associates',
|
||
|
|
site_name: null,
|
||
|
|
devices: [],
|
||
|
|
users: ['Lorentz Hinrichsen'],
|
||
|
|
applications: ['AMS360'],
|
||
|
|
vendors: ['Vertafore'],
|
||
|
|
},
|
||
|
|
is_resolved: false,
|
||
|
|
status_matches_reality: false,
|
||
|
|
complexity_tier: 'medium',
|
||
|
|
complexity_reasons: ['vendor case opened after requestor pivoted'],
|
||
|
|
itglue_lookup_needed: false,
|
||
|
|
itglue_search_hints: [],
|
||
|
|
...overrides,
|
||
|
|
});
|
||
|
|
|
||
|
|
const validSonnet = (overrides: Record<string, unknown> = {}) =>
|
||
|
|
JSON.stringify({
|
||
|
|
summary: 'Lorentz pivoted; tech opened a Vertafore case anyway.',
|
||
|
|
timeline: [
|
||
|
|
{
|
||
|
|
timestamp: '2026-04-24T14:34:46.583Z',
|
||
|
|
actor: 'Lorentz Hinrichsen',
|
||
|
|
actor_type: 'wulf_tech',
|
||
|
|
source: 'ticket_note',
|
||
|
|
visibility: 'customer_facing',
|
||
|
|
action: 'Said no further outreach to Vertafore was needed.',
|
||
|
|
},
|
||
|
|
],
|
||
|
|
what_was_done: ['Opened a case with Vertafore on 04/24'],
|
||
|
|
what_should_have_been_done: ['Confirmed with requestor before opening case'],
|
||
|
|
gaps: [
|
||
|
|
{
|
||
|
|
description: 'Work continued after requestor said to stop.',
|
||
|
|
severity: 'high',
|
||
|
|
evidence_timestamps: ['2026-04-27T00:00:00.000Z'],
|
||
|
|
},
|
||
|
|
],
|
||
|
|
next_step: 'Confirm with requestor whether Vertafore endpoint info is still useful.',
|
||
|
|
next_step_rationale: 'They effectively closed the loop on 04/24.',
|
||
|
|
post_resolution_analysis: null,
|
||
|
|
confidence_score: 0.7,
|
||
|
|
needs_human_review: false,
|
||
|
|
human_review_reasons: [],
|
||
|
|
ambiguities_for_opus: [],
|
||
|
|
itglue_docs_referenced: [],
|
||
|
|
...overrides,
|
||
|
|
});
|
||
|
|
|
||
|
|
const validOpus = (overrides: Record<string, unknown> = {}) =>
|
||
|
|
JSON.stringify({
|
||
|
|
opus_notes: 'Reviewed Sonnet analysis; agree on the high-severity gap.',
|
||
|
|
updates: {},
|
||
|
|
...overrides,
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('runPipeline', () => {
|
||
|
|
it('short-circuits when an existing analysis with the same content hash exists', async () => {
|
||
|
|
findExistingSpy.mockResolvedValueOnce({
|
||
|
|
id: 'existing-uuid',
|
||
|
|
analysis_version: 3,
|
||
|
|
});
|
||
|
|
const { fake } = makeFakeAnthropic([]);
|
||
|
|
const result = await runPipeline(
|
||
|
|
{ bundle: FIXTURE },
|
||
|
|
{ anthropic: fake }
|
||
|
|
);
|
||
|
|
expect(result.outcome).toBe('idempotent_short_circuit');
|
||
|
|
if (result.outcome === 'idempotent_short_circuit') {
|
||
|
|
expect(result.existing_analysis_id).toBe('existing-uuid');
|
||
|
|
expect(result.existing_analysis_version).toBe(3);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it('does NOT short-circuit when force=true', async () => {
|
||
|
|
findExistingSpy.mockResolvedValueOnce({
|
||
|
|
id: 'existing-uuid',
|
||
|
|
analysis_version: 3,
|
||
|
|
});
|
||
|
|
const { fake } = makeFakeAnthropic([
|
||
|
|
{ text: validTriage({ status_matches_reality: true }) },
|
||
|
|
{ text: validSonnet() },
|
||
|
|
]);
|
||
|
|
const result = await runPipeline(
|
||
|
|
{ bundle: FIXTURE, force: true },
|
||
|
|
{ anthropic: fake }
|
||
|
|
);
|
||
|
|
expect(result.outcome).toBe('complete');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('runs Stage 1 → Stage 3 happy path with no Opus when nothing triggers it', async () => {
|
||
|
|
const { fake, bodies } = makeFakeAnthropic([
|
||
|
|
{ text: validTriage({ status_matches_reality: true, complexity_tier: 'low' }) },
|
||
|
|
{
|
||
|
|
text: validSonnet({
|
||
|
|
confidence_score: 0.85,
|
||
|
|
ambiguities_for_opus: [],
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
]);
|
||
|
|
|
||
|
|
const result = await runPipeline(
|
||
|
|
{ bundle: FIXTURE },
|
||
|
|
{ anthropic: fake }
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(result.outcome).toBe('complete');
|
||
|
|
if (result.outcome !== 'complete') return;
|
||
|
|
expect(bodies).toHaveLength(2);
|
||
|
|
expect(bodies[0].model).toBe('claude-haiku-4-5');
|
||
|
|
expect(bodies[1].model).toBe('claude-sonnet-4-6');
|
||
|
|
expect(result.meta.haiku_used).toBe(true);
|
||
|
|
expect(result.meta.sonnet_used).toBe(true);
|
||
|
|
expect(result.meta.opus_used).toBe(false);
|
||
|
|
expect(result.analysis.confidence_score).toBe(0.85);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('runs Opus when triage.status_matches_reality=false', async () => {
|
||
|
|
const { fake, bodies } = makeFakeAnthropic([
|
||
|
|
{ text: validTriage({ status_matches_reality: false }) },
|
||
|
|
{ text: validSonnet({ confidence_score: 0.9 }) },
|
||
|
|
{ text: validOpus() },
|
||
|
|
]);
|
||
|
|
|
||
|
|
const result = await runPipeline(
|
||
|
|
{ bundle: FIXTURE },
|
||
|
|
{ anthropic: fake }
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(result.outcome).toBe('complete');
|
||
|
|
if (result.outcome !== 'complete') return;
|
||
|
|
expect(bodies.map((b) => b.model)).toEqual([
|
||
|
|
'claude-haiku-4-5',
|
||
|
|
'claude-sonnet-4-6',
|
||
|
|
'claude-opus-4-7',
|
||
|
|
]);
|
||
|
|
expect(result.meta.opus_used).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('applies Opus updates over the Sonnet result', async () => {
|
||
|
|
const { fake } = makeFakeAnthropic([
|
||
|
|
{ text: validTriage({ complexity_tier: 'high' }) },
|
||
|
|
{
|
||
|
|
text: validSonnet({
|
||
|
|
next_step: 'sonnet step',
|
||
|
|
confidence_score: 0.4,
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
text: validOpus({
|
||
|
|
updates: {
|
||
|
|
next_step: 'opus step',
|
||
|
|
confidence_score: 0.85,
|
||
|
|
needs_human_review: true,
|
||
|
|
human_review_reasons: ['opus flagged for review'],
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
]);
|
||
|
|
|
||
|
|
const result = await runPipeline({ bundle: FIXTURE }, { anthropic: fake });
|
||
|
|
expect(result.outcome).toBe('complete');
|
||
|
|
if (result.outcome !== 'complete') return;
|
||
|
|
expect(result.analysis.next_step).toBe('opus step');
|
||
|
|
expect(result.analysis.confidence_score).toBe(0.85);
|
||
|
|
expect(result.analysis.needs_human_review).toBe(true);
|
||
|
|
expect(result.analysis.human_review_reasons).toEqual(['opus flagged for review']);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('respects forceSkipOpus even when triggers fire', async () => {
|
||
|
|
const { fake, bodies } = makeFakeAnthropic([
|
||
|
|
{ text: validTriage({ complexity_tier: 'high' }) },
|
||
|
|
{ text: validSonnet({ ambiguities_for_opus: ['why?'] }) },
|
||
|
|
]);
|
||
|
|
const result = await runPipeline(
|
||
|
|
{ bundle: FIXTURE, forceSkipOpus: true },
|
||
|
|
{ anthropic: fake }
|
||
|
|
);
|
||
|
|
expect(result.outcome).toBe('complete');
|
||
|
|
if (result.outcome !== 'complete') return;
|
||
|
|
expect(result.meta.opus_used).toBe(false);
|
||
|
|
expect(bodies.map((b) => b.model)).toEqual([
|
||
|
|
'claude-haiku-4-5',
|
||
|
|
'claude-sonnet-4-6',
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('trips the cost circuit breaker before Opus when running cost ≥ ceiling', async () => {
|
||
|
|
const ceiling = _PIPELINE_INTERNALS.COST_CEILING_USD;
|
||
|
|
// Force the running cost above the ceiling by inflating Sonnet input/output.
|
||
|
|
// Sonnet pricing: $3/1M input, $15/1M output. Spend 700M tokens on output
|
||
|
|
// — well over $2 — to deterministically trip the breaker.
|
||
|
|
const { fake, bodies } = makeFakeAnthropic([
|
||
|
|
{ text: validTriage({ complexity_tier: 'high' }) },
|
||
|
|
{
|
||
|
|
text: validSonnet({ ambiguities_for_opus: ['why?'] }),
|
||
|
|
usage: { input_tokens: 1, output_tokens: 700_000_000 },
|
||
|
|
},
|
||
|
|
]);
|
||
|
|
|
||
|
|
const result = await runPipeline({ bundle: FIXTURE }, { anthropic: fake });
|
||
|
|
expect(result.outcome).toBe('complete');
|
||
|
|
if (result.outcome !== 'complete') return;
|
||
|
|
|
||
|
|
expect(result.meta.opus_used).toBe(false);
|
||
|
|
expect(result.meta.cost_circuit_breaker_tripped).toBe(true);
|
||
|
|
expect(result.analysis.needs_human_review).toBe(true);
|
||
|
|
expect(result.analysis.human_review_reasons.some((r) => r.includes('cost ceiling'))).toBe(true);
|
||
|
|
expect(bodies.map((b) => b.model)).toEqual([
|
||
|
|
'claude-haiku-4-5',
|
||
|
|
'claude-sonnet-4-6',
|
||
|
|
]);
|
||
|
|
expect(result.meta.estimated_cost_usd).toBeGreaterThanOrEqual(ceiling);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('runs IT Glue search when triage requests it AND succeeds', async () => {
|
||
|
|
const { fake } = makeFakeAnthropic([
|
||
|
|
{
|
||
|
|
text: validTriage({
|
||
|
|
itglue_lookup_needed: true,
|
||
|
|
itglue_search_hints: ['AMS360', 'VSSO'],
|
||
|
|
status_matches_reality: true,
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
{ text: validSonnet({ confidence_score: 0.85 }) },
|
||
|
|
]);
|
||
|
|
|
||
|
|
const fakeItglue = vi.fn().mockResolvedValue({
|
||
|
|
org_id: '42',
|
||
|
|
org_name: 'Seubert and Associates',
|
||
|
|
docs: [
|
||
|
|
{
|
||
|
|
id: 'doc1',
|
||
|
|
name: 'AMS360 Server',
|
||
|
|
doc_type: 'configuration',
|
||
|
|
organization_id: '42',
|
||
|
|
organization_name: 'Seubert and Associates',
|
||
|
|
snippet: 'sanitized snippet',
|
||
|
|
url: null,
|
||
|
|
updated_at: '2026-04-01T00:00:00.000Z',
|
||
|
|
},
|
||
|
|
],
|
||
|
|
alias_used: false,
|
||
|
|
} satisfies ITGlueSearchResult);
|
||
|
|
|
||
|
|
const result = await runPipeline(
|
||
|
|
{ bundle: FIXTURE },
|
||
|
|
{ anthropic: fake, itglueSearch: fakeItglue }
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(fakeItglue).toHaveBeenCalledTimes(1);
|
||
|
|
expect(fakeItglue).toHaveBeenCalledWith({
|
||
|
|
org_name: 'Seubert and Associates',
|
||
|
|
hints: ['AMS360', 'VSSO'],
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(result.outcome).toBe('complete');
|
||
|
|
if (result.outcome !== 'complete') return;
|
||
|
|
expect(result.itglue_search_used).toBe(true);
|
||
|
|
expect(result.itglue_org_resolved).toBe(true);
|
||
|
|
expect(result.model_traces.itglue?.doc_count).toBe(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('continues without IT Glue context when search throws', async () => {
|
||
|
|
const { fake } = makeFakeAnthropic([
|
||
|
|
{
|
||
|
|
text: validTriage({
|
||
|
|
itglue_lookup_needed: true,
|
||
|
|
itglue_search_hints: ['AMS360'],
|
||
|
|
status_matches_reality: true,
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
{ text: validSonnet({ confidence_score: 0.85 }) },
|
||
|
|
]);
|
||
|
|
|
||
|
|
const fakeItglue = vi.fn().mockRejectedValue(new Error('IT Glue 502'));
|
||
|
|
const result = await runPipeline(
|
||
|
|
{ bundle: FIXTURE },
|
||
|
|
{ anthropic: fake, itglueSearch: fakeItglue }
|
||
|
|
);
|
||
|
|
expect(result.outcome).toBe('complete');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('reports filtered_noise_count from preprocessor', async () => {
|
||
|
|
const { fake } = makeFakeAnthropic([
|
||
|
|
{ text: validTriage({ status_matches_reality: true, complexity_tier: 'low' }) },
|
||
|
|
{ text: validSonnet({ confidence_score: 0.85 }) },
|
||
|
|
]);
|
||
|
|
const result = await runPipeline({ bundle: FIXTURE }, { anthropic: fake });
|
||
|
|
expect(result.outcome).toBe('complete');
|
||
|
|
if (result.outcome !== 'complete') return;
|
||
|
|
// T20260424.0045 has 4 workflow-rule firings + 4 service-desk-notification rows = 8 noise.
|
||
|
|
expect(result.filtered_noise_count).toBe(8);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('drives onStage callbacks in order', async () => {
|
||
|
|
const stages: string[] = [];
|
||
|
|
const { fake } = makeFakeAnthropic([
|
||
|
|
{ text: validTriage({ status_matches_reality: true, complexity_tier: 'low' }) },
|
||
|
|
{ text: validSonnet({ confidence_score: 0.85 }) },
|
||
|
|
]);
|
||
|
|
await runPipeline(
|
||
|
|
{ bundle: FIXTURE },
|
||
|
|
{ anthropic: fake },
|
||
|
|
{ onStage: (stage) => void stages.push(stage) }
|
||
|
|
);
|
||
|
|
expect(stages).toEqual(['fetching', 'triaging', 'analyzing']);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('drives onStage callbacks including itglue and deep_review when applicable', async () => {
|
||
|
|
const stages: string[] = [];
|
||
|
|
const { fake } = makeFakeAnthropic([
|
||
|
|
{
|
||
|
|
text: validTriage({
|
||
|
|
itglue_lookup_needed: true,
|
||
|
|
itglue_search_hints: ['x'],
|
||
|
|
complexity_tier: 'high',
|
||
|
|
status_matches_reality: false,
|
||
|
|
}),
|
||
|
|
},
|
||
|
|
{ text: validSonnet({ ambiguities_for_opus: ['?'] }) },
|
||
|
|
{ text: validOpus() },
|
||
|
|
]);
|
||
|
|
const fakeItglue = vi.fn().mockResolvedValue({
|
||
|
|
org_id: '1',
|
||
|
|
org_name: 'X',
|
||
|
|
docs: [],
|
||
|
|
alias_used: false,
|
||
|
|
} satisfies ITGlueSearchResult);
|
||
|
|
|
||
|
|
await runPipeline(
|
||
|
|
{ bundle: FIXTURE },
|
||
|
|
{ anthropic: fake, itglueSearch: fakeItglue },
|
||
|
|
{ onStage: (stage) => void stages.push(stage) }
|
||
|
|
);
|
||
|
|
expect(stages).toEqual([
|
||
|
|
'fetching',
|
||
|
|
'triaging',
|
||
|
|
'itglue',
|
||
|
|
'analyzing',
|
||
|
|
'deep_review',
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
});
|