diff --git a/src/__tests__/wizard-abort.test.ts b/src/__tests__/wizard-abort.test.ts index 8a72e0cf..6d851be1 100644 --- a/src/__tests__/wizard-abort.test.ts +++ b/src/__tests__/wizard-abort.test.ts @@ -132,6 +132,34 @@ describe('wizardAbort', () => { }); }); + it('does not capture an expected abort as an exception', async () => { + // A benign, user-driven abort (e.g. declining the GitHub connection) still + // carries a WizardError for context/logging, but must not reach error + // tracking — it is a normal outcome, not an exception to triage. + const error = new WizardError('Agent aborted: github connection declined', { + integration: 'self-driving', + error_type: 'ABORT', + }); + + await expect(wizardAbort({ error, expected: true })).rejects.toThrow( + 'process.exit called', + ); + + expect(mockAnalytics.captureException).not.toHaveBeenCalled(); + expect(mockAnalytics.shutdown).toHaveBeenCalledWith('cancelled'); + }); + + it('still captures an unexpected abort even when an error is provided', async () => { + const error = new WizardError('Agent aborted: unreachable', {}); + + await expect(wizardAbort({ error, expected: false })).rejects.toThrow( + 'process.exit called', + ); + + expect(mockAnalytics.captureException).toHaveBeenCalledWith(error, {}); + expect(mockAnalytics.shutdown).toHaveBeenCalledWith('error'); + }); + it('runs registered cleanup functions before analytics and display', async () => { const callOrder: string[] = []; diff --git a/src/lib/agent/runner/sequence/linear.ts b/src/lib/agent/runner/sequence/linear.ts index d851a210..02e3d5d9 100644 --- a/src/lib/agent/runner/sequence/linear.ts +++ b/src/lib/agent/runner/sequence/linear.ts @@ -168,6 +168,10 @@ export async function runLinearProgram( }); await wizardAbort({ outroData, + // A matched, `expected` abort case (e.g. the user declining the GitHub + // connection) is a benign outcome — render the outro and fire the + // `agent aborted` event above, but keep it out of error tracking. + expected: matched?.expected ?? false, error: new WizardError(`Agent aborted: ${reason}`, { integration: config.integrationLabel, error_type: AgentErrorType.ABORT, diff --git a/src/lib/agent/runner/shared/types.ts b/src/lib/agent/runner/shared/types.ts index a5f724ea..d284d91e 100644 --- a/src/lib/agent/runner/shared/types.ts +++ b/src/lib/agent/runner/shared/types.ts @@ -23,6 +23,14 @@ export interface AbortCase { message: string; body: string; docsUrl?: string; + /** + * Mark a benign, user-driven or environment-driven abort (e.g. the user + * declining an optional connection) as expected. Expected aborts still + * render their outro and fire the `agent aborted` analytics event, but are + * NOT reported to error tracking via `captureException` — they are normal + * outcomes, not exceptions worth triaging. + */ + expected?: boolean; } /** diff --git a/src/lib/programs/__tests__/self-driving-detect.test.ts b/src/lib/programs/__tests__/self-driving-detect.test.ts index b0308656..c00cc9fb 100644 --- a/src/lib/programs/__tests__/self-driving-detect.test.ts +++ b/src/lib/programs/__tests__/self-driving-detect.test.ts @@ -69,6 +69,30 @@ describe('SELF_DRIVING_ABORT_CASES', () => { expect(matched[0].body).toBeTruthy(); }); + it('marks benign, user/environment-driven aborts as expected (kept out of error tracking)', () => { + // Declining GitHub or running non-interactively are normal outcomes, not + // exceptions worth triaging — they must be flagged `expected` so + // wizardAbort skips captureException. + const expectedReasons = [ + 'github connection declined', + 'requires-interactive-mode', + 'requirements-incomplete', + ]; + for (const reason of expectedReasons) { + const [c] = SELF_DRIVING_ABORT_CASES.filter((c) => c.match.test(reason)); + expect(c?.expected, reason).toBe(true); + } + }); + + it('keeps the unavailable-access abort as a genuine (unexpected) error', () => { + // Signals being unreachable in open beta is a real failure — it should + // still be reported to error tracking. + const [c] = SELF_DRIVING_ABORT_CASES.filter((c) => + c.match.test('self-driving is not available for this project'), + ); + expect(c?.expected).not.toBe(true); + }); + it('frames the unavailable-access abort as open beta, not a closed per-team beta', () => { // STEP 1 no longer gates on access — Self-driving is open beta — but the // abort is kept as a safety net. Its copy must say the product is still diff --git a/src/lib/programs/self-driving/detect.ts b/src/lib/programs/self-driving/detect.ts index c5a638c5..864179a5 100644 --- a/src/lib/programs/self-driving/detect.ts +++ b/src/lib/programs/self-driving/detect.ts @@ -109,6 +109,10 @@ export const SELF_DRIVING_ABORT_CASES: AbortCase[] = [ 'open fixes, so setup cannot finish without it. Nothing was left ' + 'half-configured. When you are ready to install the PostHog GitHub ' + 'App, run the wizard again.', + // The user chose not to connect GitHub — an expected cancellation, not an + // error. The friendly outro above already explains next steps; don't file + // an error-tracking exception for it. + expected: true, }, { // Skill emits: [ABORT] requires-interactive-mode @@ -118,6 +122,9 @@ export const SELF_DRIVING_ABORT_CASES: AbortCase[] = [ 'Self-driving setup asks questions along the way (GitHub and ' + 'issue trackers), so it needs an interactive terminal. Run ' + 'the wizard outside CI / non-interactive mode.', + // Running in a non-interactive environment is an expected precondition + // failure, not an exception to triage. + expected: true, }, { // The wizard_ask tool's own error texts (non-interactive host, ask cap @@ -130,6 +137,9 @@ export const SELF_DRIVING_ABORT_CASES: AbortCase[] = [ 'environment was non-interactive, or the question budget ran out). ' + 'Nothing was left half-configured. Run the wizard again in an ' + 'interactive terminal.', + // Couldn't collect the required answers (non-interactive / budget) — a + // benign, expected outcome rather than an error. + expected: true, }, ]; diff --git a/src/utils/wizard-abort.ts b/src/utils/wizard-abort.ts index fe844728..2d71d202 100644 --- a/src/utils/wizard-abort.ts +++ b/src/utils/wizard-abort.ts @@ -27,6 +27,13 @@ interface WizardAbortOptions { outroData?: OutroData; error?: Error | WizardError; exitCode?: number; + /** + * Mark this as an expected, benign abort (e.g. the user declining an + * optional connection). The outro still renders and analytics still shut + * down cleanly, but the `error` is NOT sent to error tracking via + * `captureException` — it is a normal outcome, not an exception to triage. + */ + expected?: boolean; } const cleanupFns: Array<() => void> = []; @@ -59,6 +66,7 @@ export async function wizardAbort( outroData, error, exitCode = 1, + expected = false, } = options ?? {}; logToFile(`[wizard-abort] exitCode=${exitCode}, message: ${message}`); @@ -69,15 +77,17 @@ export async function wizardAbort( // 1. Run registered cleanup functions runCleanups(); - // 2. Capture error in analytics (if provided) - if (error) { + // 2. Capture error in analytics (if provided). Skip for expected aborts — + // e.g. a user declining an optional connection — so a normal cancellation + // does not pollute error tracking with a WizardError exception. + if (error && !expected) { analytics.captureException(error, { ...((error instanceof WizardError && error.context) || {}), }); } // 3. Shutdown analytics - await analytics.shutdown(error ? 'error' : 'cancelled'); + await analytics.shutdown(error && !expected ? 'error' : 'cancelled'); // 4. Render the error outro. Synthesize OutroData from `message` // when the caller didn't provide structured data.