Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/lib/__tests__/agent-interface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
buildAuthErrorContext,
buildAgentEnv,
reportMcpSetup,
AgentErrorType,
} from '@lib/agent/agent-interface';
import { AgentOutputSignals } from '@lib/agent/output-signals';
import { RESUME_INSTRUCTION } from '@lib/agent/signals';
Expand Down Expand Up @@ -120,6 +121,49 @@ describe('runAgent', () => {
Object.values(mockUIInstance.log).forEach((fn) => fn.mockReset());
});

it('aborts an unfinished SDK run at its configured timeout', async () => {
vi.useFakeTimers();
let controller: AbortController | undefined;
let pending: Promise<unknown> | undefined;
try {
mockQuery.mockImplementation(({ options }) => {
controller = options.abortController;
return (async function* () {
await new Promise<void>((resolve) => {
if (controller?.signal.aborted) resolve();
else
controller?.signal.addEventListener('abort', () => resolve(), {
once: true,
});
});
if (controller?.signal.aborted) throw new Error('SDK query aborted');
yield { type: 'assistant', message: { content: [] } };
})();
});

pending = runAgent(
defaultAgentConfig,
'test prompt',
defaultOptions,
mockSpinner as unknown as SpinnerHandle,
{ timeoutMs: 60_000 },
).catch((error) => error);
await vi.waitFor(() => expect(controller).toBeDefined());
await vi.advanceTimersByTimeAsync(60_000);

expect(controller?.signal.aborted).toBe(true);
expect(await pending).toEqual({
error: AgentErrorType.AGENTIC_DETECTION_TIMEOUT,
message: 'Agent run timed out after 60s',
});
expect(mockSpinner.stop).toHaveBeenCalledWith('Agent run timed out');
} finally {
controller?.abort();
if (pending) await pending;
vi.useRealTimers();
}
});

describe('race condition handling', () => {
it('should return success when agent completes successfully then SDK cleanup fails', async () => {
// This simulates the race condition:
Expand Down
29 changes: 29 additions & 0 deletions src/lib/agent/agent-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,8 @@ export async function runAgent(
* aborted` events (e.g. the orchestrator's task type and id).
*/
analyticsProperties?: Record<string, unknown>;
/** Abort the SDK query when this run exceeds its own deadline. */
timeoutMs?: number;
},
middleware?: {
onMessage(message: any): void;
Expand Down Expand Up @@ -869,6 +871,7 @@ export async function runAgent(
// [ABORT] signal in the agent's output. Also stashes the reason so the
// runner can surface it via outroData after we unwind.
let abortController = new AbortController();
let timedOut = false;
let abortReason: string | null = null;
// Set when a YARA hook detects a terminal violation. Returning `stopReason`
// from a PostToolUse hook does NOT stop the SDK, so we abort the query and
Expand All @@ -880,6 +883,15 @@ export async function runAgent(
let reminted = false;
let remintRequested = false;
const agentConfigDir = createIsolatedAgentConfigDir();
const timeoutMs = config?.timeoutMs;
const timeoutId = timeoutMs
? setTimeout(() => {
if (receivedSuccessResult) return;
timedOut = true;
signalDone();
abortController.abort();
}, timeoutMs)
: undefined;

try {
// Per-program allow/disallow lists tweak BASE_ALLOWED_TOOLS. Skills are
Expand Down Expand Up @@ -1335,6 +1347,14 @@ export async function runAgent(
return { error: AgentErrorType.ABORT, message: abortReason };
}

if (timedOut) {
spinner.stop('Agent run timed out');
return {
error: AgentErrorType.AGENTIC_DETECTION_TIMEOUT,
message: `Agent run timed out after ${timeoutMs! / 1000}s`,
};
}

// Check for error markers in the agent's output
if (signals.has('MCP_MISSING')) {
logToFile('Agent error: MCP_MISSING');
Expand Down Expand Up @@ -1403,6 +1423,14 @@ export async function runAgent(
return completeWithSuccess(error as Error);
}

if (timedOut) {
spinner.stop('Agent run timed out');
return {
error: AgentErrorType.AGENTIC_DETECTION_TIMEOUT,
message: `Agent run timed out after ${timeoutMs! / 1000}s`,
};
}

// Check if we collected an error signal before the exception was thrown.
// Surface just the API error line(s), not the entire output.
const apiErrorMessage = signals.apiErrorMessage() ?? 'Unknown API error';
Expand All @@ -1426,6 +1454,7 @@ export async function runAgent(
debug('Full error:', error);
throw error;
} finally {
if (timeoutId) clearTimeout(timeoutId);
// Always capture run duration, even on abort/error, so we can alert on
// long runs where the user gave up before completion.
if (!receivedSuccessResult) {
Expand Down
2 changes: 2 additions & 0 deletions src/lib/agent/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export const RESUME_INSTRUCTION =
* These correspond to the error signals that the agent emits.
*/
export enum AgentErrorType {
/** The configured run deadline elapsed and the SDK query was aborted */
AGENTIC_DETECTION_TIMEOUT = 'WIZARD_AGENTIC_DETECTION_TIMEOUT',
/** Agent could not access the PostHog MCP server */
MCP_MISSING = 'WIZARD_MCP_MISSING',
/** Agent could not access the setup resource */
Expand Down
3 changes: 2 additions & 1 deletion src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,8 @@ export function wizardUserAgentForProgram(programId?: string): string {
export const DETECTION_TIMEOUT_MS = 10_000;

/** Timeout for the agentic project scan (ms); past it the run falls back to root detection. */
export const AGENTIC_DETECTION_TIMEOUT_MS = 60_000;
export const AGENTIC_DETECTION_FIRST_ATTEMPT_TIMEOUT_MS = 60_000;
export const AGENTIC_DETECTION_RETRY_TIMEOUT_MS = 90_000;

/**
* Timeout for the OAuth authorization flow (ms).
Expand Down
185 changes: 185 additions & 0 deletions src/lib/detection/__tests__/agentic-retry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import {
AgenticDetectionTimeoutError,
detectProjectsWithAgent,
} from '@lib/detection/agentic';
import {
AgentErrorType,
initializeAgent,
runAgent,
} from '@lib/agent/agent-interface';
import { buildSession } from '@lib/wizard-session';
import { HostResolution } from '@lib/host-resolution';

vi.mock('@lib/agent/agent-interface', async (importOriginal) => ({
...(await importOriginal<typeof import('@lib/agent/agent-interface')>()),
initializeAgent: vi.fn(),
runAgent: vi.fn(),
}));

const init = vi.mocked(initializeAgent);
const execute = vi.mocked(runAgent);
const options = {
programId: 'posthog-integration',
targets: [{ id: 'nextjs', name: 'Next.js' }],
};

function session() {
const value = buildSession({ installDir: '/repo' });
value.credentials = {
accessToken: 'token',
projectApiKey: 'key',
host: HostResolution.fromApiHost('https://us.posthog.com'),
projectId: 1,
};
return value;
}

function emitResult(text: string) {
return execute.mockImplementationOnce((...args) => {
args[5]?.onMessage({ type: 'result', result: text });
return Promise.resolve({});
});
}

describe('agentic detection retry', () => {
beforeEach(() => {
vi.resetAllMocks();
init.mockImplementation(() =>
Promise.resolve({ id: init.mock.calls.length } as unknown as Awaited<
ReturnType<typeof initializeAgent>
>),
);
});

it('restarts the scan once when the first result has no JSON', async () => {
emitResult('I found a Next.js project.');
emitResult(
'{"path":".","framework":"Next.js","targetId":"nextjs","hasPostHog":false}',
);

const report = await detectProjectsWithAgent(session(), options);

expect(report.projects).toEqual([
{
path: '.',
framework: 'Next.js',
targetId: 'nextjs',
hasPostHog: false,
},
]);
expect(init).toHaveBeenCalledTimes(2);
expect(execute).toHaveBeenCalledTimes(2);
expect(execute.mock.calls[0][4]).toEqual(
expect.objectContaining({ timeoutMs: 60_000 }),
);
expect(execute.mock.calls[1][4]).toEqual(
expect.objectContaining({ timeoutMs: 90_000 }),
);
});

it('returns the first valid report without starting a retry', async () => {
emitResult(
'{"path":".","framework":"Next.js","targetId":"nextjs","hasPostHog":false}',
);

const report = await detectProjectsWithAgent(session(), options);

expect(report.projects).toHaveLength(1);
expect(init).toHaveBeenCalledTimes(1);
expect(execute).toHaveBeenCalledTimes(1);
});

it('retries a timed-out first run with a fresh Haiku session', async () => {
const events: string[] = [];
execute.mockResolvedValueOnce({
error: AgentErrorType.AGENTIC_DETECTION_TIMEOUT,
});
emitResult(
'{"path":".","framework":"Next.js","targetId":"nextjs","hasPostHog":false}',
);

const report = await detectProjectsWithAgent(session(), {
...options,
onEvent: (line) => events.push(line),
});

expect(report.projects).toHaveLength(1);
expect(events).toContain('Project scan timed out; retrying...');
expect(execute.mock.calls[0][0]).not.toBe(execute.mock.calls[1][0]);
expect(execute.mock.calls[0][4]).toEqual(
expect.objectContaining({ timeoutMs: 60_000 }),
);
expect(execute.mock.calls[1][4]).toEqual(
expect.objectContaining({ timeoutMs: 90_000 }),
);
});

it('reports a typed timeout when the retry also times out', async () => {
execute.mockResolvedValue({
error: AgentErrorType.AGENTIC_DETECTION_TIMEOUT,
});

await expect(detectProjectsWithAgent(session(), options)).rejects.toThrow(
AgenticDetectionTimeoutError,
);
expect(execute).toHaveBeenCalledTimes(2);
});

it('accepts a streamed verdict after a no-JSON result', async () => {
const events: string[] = [];
emitResult('Found a project, but no JSON report.');
execute.mockImplementationOnce((...args) => {
args[5]?.onMessage({
type: 'assistant',
message: {
content: [
{
type: 'text',
text: '{"path":".","framework":"Next.js","targetId":"nextjs","hasPostHog":false}',
},
],
},
});
args[5]?.onMessage({ type: 'result', result: 'Done.' });
return Promise.resolve({});
});

const report = await detectProjectsWithAgent(session(), {
...options,
onEvent: (line) => events.push(line),
});

expect(report.projects).toEqual([
{
path: '.',
framework: 'Next.js',
targetId: 'nextjs',
hasPostHog: false,
},
]);
expect(events).toContain('Retrying project scan...');
expect(init).toHaveBeenCalledTimes(2);
expect(execute.mock.calls[0][0]).not.toBe(execute.mock.calls[1][0]);
expect(execute.mock.calls[0][1]).toBe(execute.mock.calls[1][1]);
});

it('stops after one retry if neither result has JSON', async () => {
emitResult('No JSON here.');
emitResult('Still no JSON.');

await expect(detectProjectsWithAgent(session(), options)).rejects.toThrow(
'Agent did not return a JSON object after retry',
);
expect(execute).toHaveBeenCalledTimes(2);
});

it('does not retry an intentional abort', async () => {
emitResult('[ABORT] detection failed');

await expect(detectProjectsWithAgent(session(), options)).resolves.toEqual({
repoType: 'single',
projects: [],
});
expect(execute).toHaveBeenCalledTimes(1);
});
});
39 changes: 28 additions & 11 deletions src/lib/detection/__tests__/project-scope.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import {
detectProjectsWithAgent,
AgenticDetectionTimeoutError,
type AgenticProject,
} from '@lib/detection/agentic';
import {
chooseIntegrationProject,
scopeInstallDirToProject,
} from '@lib/detection/project-scope';
import {
AGENTIC_DETECTION_TIMEOUT_MS,
WIZARD_BASIC_INTEGRATION_AGENTIC_DETECTION_FLAG_KEY,
} from '@lib/constants';
import { WIZARD_BASIC_INTEGRATION_AGENTIC_DETECTION_FLAG_KEY } from '@lib/constants';
import { authenticate } from '@lib/agent/runner/shared/authenticate';
import { buildSession } from '@lib/wizard-session';
import { analytics } from '@utils/analytics';
Expand Down Expand Up @@ -194,21 +192,40 @@ describe('scopeInstallDirToProject', () => {
});
});

it('leaves the session untouched and fires timeout when the scan outruns the budget', async () => {
// The scan is abandoned, not cancelled — the run must not wait on it forever.
it('leaves the session untouched and fires timeout after the retry deadline', async () => {
flagsSpy.mockResolvedValue(FLAG_ON);
scan.mockRejectedValue(new AgenticDetectionTimeoutError(2, 90_000));
const session = buildSession({ installDir: '/repo' });

await scopeInstallDirToProject(session);

expect(session.installDir).toBe('/repo');
expect(outcomeEvent()).toMatchObject({ outcome: 'timeout' });
});

it('uses a valid retry result after the old 60-second caller deadline', async () => {
vi.useFakeTimers();
try {
flagsSpy.mockResolvedValue(FLAG_ON);
scan.mockReturnValue(new Promise(() => undefined));
scan.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(
() => resolve({ repoType: 'monorepo', projects: [web] }),
65_000,
),
),
);
const session = buildSession({ installDir: '/repo' });
const done = scopeInstallDirToProject(session);
await vi.advanceTimersByTimeAsync(AGENTIC_DETECTION_TIMEOUT_MS);

await vi.advanceTimersByTimeAsync(65_000);
await done;

expect(session.installDir).toBe('/repo');
expect(session.installDir).toBe('/repo/apps/web');
expect(outcomeEvent()).toMatchObject({
outcome: 'timeout',
duration_ms: AGENTIC_DETECTION_TIMEOUT_MS,
outcome: 'recommended',
duration_ms: 65_000,
});
} finally {
vi.useRealTimers();
Expand Down
Loading
Loading