From 583a41640382c2bdd752adc054c01db4c2e03555 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:47:32 +0000 Subject: [PATCH] fix(runner): skip exception capture for matched aborts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A matched abort case in runLinearProgram (e.g. the self-driving skill emitting `[ABORT] github connection declined`) is an expected, user-driven outcome that is already surfaced through a friendly error outro. Previously every abort — matched or not — built a WizardError and handed it to wizardAbort(), which unconditionally calls analytics.captureException(), spawning a spurious error-tracking issue for a normal user decision. Only pass an `error` to wizardAbort() for genuine, unmatched aborts. The `agent aborted` product-analytics event still fires either way, so no real signal is lost. Generated-By: PostHog Code Task-Id: 99261312-1bbc-4d65-86ba-e179b8e7b042 --- .../runner/__tests__/linear-abort.test.ts | 161 ++++++++++++++++++ src/lib/agent/runner/sequence/linear.ts | 17 +- 2 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 src/lib/agent/runner/__tests__/linear-abort.test.ts diff --git a/src/lib/agent/runner/__tests__/linear-abort.test.ts b/src/lib/agent/runner/__tests__/linear-abort.test.ts new file mode 100644 index 00000000..b6f24bbc --- /dev/null +++ b/src/lib/agent/runner/__tests__/linear-abort.test.ts @@ -0,0 +1,161 @@ +/** + * Regression tests for the ABORT branch of `runLinearProgram`. + * + * A matched abort case (e.g. the self-driving skill emitting + * `[ABORT] github connection declined`) is an expected, user-driven outcome + * already surfaced via the error outro — it must NOT be handed to + * `wizardAbort` as a `WizardError`, because that captures a spurious exception + * and spawns a bogus error-tracking issue. Unmatched aborts still capture. + */ +import { runLinearProgram } from '@lib/agent/runner/sequence/linear'; +import { AgentErrorType } from '@lib/agent/agent-interface'; +import { wizardAbort, WizardError } from '@utils/wizard-abort'; +import { analytics } from '@utils/analytics'; +import type { + ProgramRun, + BootstrapResult, +} from '@lib/agent/runner/shared/types'; +import type { ProgramConfig } from '@lib/programs/program-step'; +import type { WizardSession } from '@lib/wizard-session'; + +vi.mock('@utils/wizard-abort', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // Throw so execution stops at the abort branch, mirroring process.exit(). + wizardAbort: vi.fn(() => { + throw new Error('wizardAbort called'); + }), + registerCleanup: vi.fn(), + }; +}); + +vi.mock('@utils/analytics', () => ({ + analytics: { + wizardCapture: vi.fn(), + captureException: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + }, +})); + +vi.mock('@lib/agent/agent-prompt', () => ({ + assemblePrompt: vi.fn().mockReturnValue('prompt'), +})); + +vi.mock('@lib/wizard-ask-bridge', () => ({ + createWizardAskBridge: vi.fn().mockReturnValue(undefined), +})); + +vi.mock('@lib/agent/claude-settings', () => ({ + restoreClaudeSettings: vi.fn(), +})); + +vi.mock('@lib/agent/runner/shared/bootstrap', () => ({ + shouldDisableAsk: vi.fn().mockReturnValue(true), + sessionToOptions: vi.fn().mockReturnValue({}), +})); + +const runAgent = vi.fn(); +vi.mock('@lib/agent/runner/switchboard', () => ({ + resolveHarness: vi.fn().mockReturnValue({ harness: 'test', model: 'test' }), + getHarness: vi.fn(() => ({ run: runAgent })), +})); + +vi.mock('@ui', () => ({ + getUI: vi.fn().mockReturnValue({ + spinner: vi.fn().mockReturnValue({}), + onEnterScreen: vi.fn(), + startRun: vi.fn(), + requestQuestion: vi.fn(), + setOutroData: vi.fn(), + outro: vi.fn(), + }), +})); + +const mockWizardAbort = wizardAbort as unknown as Mock; +const mockAnalytics = analytics as unknown as { + wizardCapture: Mock; + captureException: Mock; + shutdown: Mock; +}; + +const GITHUB_DECLINED = { + match: /^github connection declined$/i, + message: 'GitHub connection required', + body: 'Run the wizard again when you are ready.', +}; + +const baseConfig = (): ProgramRun => ({ + integrationLabel: 'self-driving', + spinnerMessage: 'working', + successMessage: 'done', + estimatedDurationMinutes: 1, + reportFile: 'report.md', + docsUrl: 'https://posthog.com/docs', + abortCases: [GITHUB_DECLINED], +}); + +const baseBoot = (): BootstrapResult => + ({ + skillsBaseUrl: 'https://skills', + projectApiKey: 'phc_test', + host: 'https://us.posthog.com', + accessToken: 'token', + projectId: '1', + cloudRegion: 'us', + mcpUrl: 'https://mcp', + wizardFlags: {}, + wizardMetadata: {}, + project: null, + } as unknown as BootstrapResult); + +const session = {} as unknown as WizardSession; +const programConfig = { id: 'self-driving' } as unknown as ProgramConfig; + +const run = () => + runLinearProgram(session, baseConfig(), programConfig, baseBoot()); + +describe('runLinearProgram ABORT handling', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does not capture an exception for a matched abort case', async () => { + runAgent.mockResolvedValue({ + error: AgentErrorType.ABORT, + message: 'github connection declined', + }); + + await expect(run()).rejects.toThrow('wizardAbort called'); + + // Product-analytics signal is preserved... + expect(mockAnalytics.wizardCapture).toHaveBeenCalledWith( + 'agent aborted', + expect.objectContaining({ matched: 'GitHub connection required' }), + ); + // ...but no error is handed to wizardAbort, so no exception is captured. + expect(mockWizardAbort).toHaveBeenCalledTimes(1); + expect(mockWizardAbort.mock.calls[0][0].error).toBeUndefined(); + }); + + it('captures a WizardError for an unmatched abort case', async () => { + runAgent.mockResolvedValue({ + error: AgentErrorType.ABORT, + message: 'something genuinely broke', + }); + + await expect(run()).rejects.toThrow('wizardAbort called'); + + expect(mockAnalytics.wizardCapture).toHaveBeenCalledWith( + 'agent aborted', + expect.objectContaining({ matched: null }), + ); + expect(mockWizardAbort).toHaveBeenCalledTimes(1); + const passedError = mockWizardAbort.mock.calls[0][0].error; + expect(passedError).toBeInstanceOf(WizardError); + expect(passedError.context).toMatchObject({ + error_type: AgentErrorType.ABORT, + reason: 'something genuinely broke', + }); + }); +}); diff --git a/src/lib/agent/runner/sequence/linear.ts b/src/lib/agent/runner/sequence/linear.ts index d851a210..92b32ebb 100644 --- a/src/lib/agent/runner/sequence/linear.ts +++ b/src/lib/agent/runner/sequence/linear.ts @@ -166,13 +166,20 @@ export async function runLinearProgram( reason, matched: matched?.message ?? null, }); + // A matched abort case is an expected, user-driven outcome (e.g. declining + // the GitHub connection) already surfaced through `outroData`. Capturing it + // as a WizardError exception spawns spurious error-tracking issues, so only + // pass an `error` for genuine, unmatched aborts. The `agent aborted` + // product-analytics event above still fires either way, so no signal is lost. await wizardAbort({ outroData, - error: new WizardError(`Agent aborted: ${reason}`, { - integration: config.integrationLabel, - error_type: AgentErrorType.ABORT, - reason, - }), + error: matched + ? undefined + : new WizardError(`Agent aborted: ${reason}`, { + integration: config.integrationLabel, + error_type: AgentErrorType.ABORT, + reason, + }), }); }