/** * Tests for parseAgentXml summary path (PATHFINDER plan 03 phase 1). * * Validates that the discriminated-union parser: * - rejects responses with no recognised root element (`{ valid: false }`), * - rejects empty / no-sub-tag blocks (former #1360 false-positive), * - returns a populated summary when at least one sub-tag is present, * - treats as a first-class summary case, * - DOES NOT coerce blocks into summary fields (former * #1633 fallback path is deleted; the caller must mark the message failed * and let the retry ladder do its job — principle 1 + principle 2). */ import { describe, it, expect, mock } from 'bun:test'; mock.module('../../src/services/domain/ModeManager.js', () => ({ ModeManager: { getInstance: () => ({ getActiveMode: () => ({ observation_types: [{ id: 'bugfix' }, { id: 'discovery' }, { id: 'refactor' }], }), }), }, })); import { parseAgentXml } from '../../src/sdk/parser.js'; describe('parseAgentXml — summaries', () => { it('returns invalid when response is plain text (no XML)', () => { const result = parseAgentXml('Some plain text response without any XML tags'); expect(result.valid).toBe(false); }); it('returns invalid when has no sub-tags (false positive — was #1360)', () => { // observation response that accidentally contains some text const result = parseAgentXml('done some content here'); // The first root is , which has no parseable content; result must be invalid. expect(result.valid).toBe(false); }); it('returns invalid for bare with only plain text, no sub-tags', () => { const result = parseAgentXml('This session was productive.'); expect(result.valid).toBe(false); }); it('returns valid summary when at least one sub-tag is present', () => { const text = `Fix the bug`; const result = parseAgentXml(text); expect(result.valid).toBe(true); if (result.valid && result.kind === 'summary') { expect(result.data.request).toBe('Fix the bug'); expect(result.data.investigated).toBeNull(); expect(result.data.learned).toBeNull(); } }); it('returns full summary when all fields are present', () => { const text = ` Fix login bug Auth flow and JWT expiry Token was expiring too soon Extended token TTL to 24h Monitor error rates `; const result = parseAgentXml(text); expect(result.valid).toBe(true); if (result.valid && result.kind === 'summary') { expect(result.data.request).toBe('Fix login bug'); expect(result.data.investigated).toBe('Auth flow and JWT expiry'); expect(result.data.learned).toBe('Token was expiring too soon'); expect(result.data.completed).toBe('Extended token TTL to 24h'); expect(result.data.next_steps).toBe('Monitor error rates'); } }); it('treats as a first-class summary with skipped:true', () => { const result = parseAgentXml(''); expect(result.valid).toBe(true); if (result.valid && result.kind === 'summary') { expect(result.data.skipped).toBe(true); expect(result.data.skip_reason).toBe('no work done'); } }); it('does NOT coerce into a summary (former #1633 path deleted)', () => { const result = parseAgentXml('foo'); expect(result.valid).toBe(true); if (result.valid) { expect(result.kind).toBe('observation'); } }); it('prefers over when both present', () => { const text = `obs title summary request`; const result = parseAgentXml(text); // First root by position is observation → that wins. Caller must pick the // right turn (summary vs observation) by sending only summary prompts on // summary turns. This is the contract; it is not coercion. expect(result.valid).toBe(true); if (result.valid) { expect(result.kind).toBe('observation'); } }); it('returns invalid for empty input', () => { expect(parseAgentXml('').valid).toBe(false); expect(parseAgentXml(' \n ').valid).toBe(false); }); });