Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
185 changes: 185 additions & 0 deletions src/lib/__tests__/agent-interface.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { runAgent } from '../agent-interface';
import type { WizardOptions } from '../../utils/types';

// Mock dependencies
jest.mock('../../utils/clack');
jest.mock('../../utils/analytics');
jest.mock('../../utils/debug');

// Mock the SDK module
const mockQuery = jest.fn();
jest.mock('@anthropic-ai/claude-agent-sdk', () => ({
query: (...args: unknown[]) => mockQuery(...args),
}));

// Get mocked clack for spinner
import clack from '../../utils/clack';
const mockClack = clack as jest.Mocked<typeof clack>;

describe('runAgent', () => {
let mockSpinner: {
start: jest.Mock;
stop: jest.Mock;
message: string;
};

const defaultOptions: WizardOptions = {
debug: false,
installDir: '/test/dir',
forceInstall: false,
default: false,
signup: false,
localMcp: false,
ci: false,
};

const defaultAgentConfig = {
workingDirectory: '/test/dir',
mcpServers: {},
model: 'claude-opus-4-5-20251101',
};

beforeEach(() => {
jest.clearAllMocks();

mockSpinner = {
start: jest.fn(),
stop: jest.fn(),
message: '',
};

mockClack.spinner = jest.fn().mockReturnValue(mockSpinner);
mockClack.log = {
step: jest.fn(),
success: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
warning: jest.fn(),
info: jest.fn(),
message: jest.fn(),
};
});

describe('race condition handling', () => {
it('should return success when agent completes successfully then SDK cleanup fails', async () => {
// This simulates the race condition:
// 1. Agent completes with success result
// 2. signalDone() is called, completing the prompt generator
// 3. SDK tries to send cleanup command while streaming is active
// 4. SDK throws an error
// The fix should recognize we already got a success and return success anyway

function* mockGeneratorWithCleanupError() {
yield {
type: 'system',
subtype: 'init',
model: 'claude-opus-4-5-20251101',
tools: [],
mcp_servers: [],
};

yield {
type: 'result',
subtype: 'success',
is_error: false,
result: 'Agent completed successfully',
};

// Simulate the SDK cleanup error that occurs after success
throw new Error('only prompt commands are supported in streaming mode');
}

mockQuery.mockReturnValue(mockGeneratorWithCleanupError());

const result = await runAgent(
defaultAgentConfig,
'test prompt',
defaultOptions,
mockSpinner as unknown as ReturnType<typeof clack.spinner>,
{
successMessage: 'Test success',
errorMessage: 'Test error',
},
);

// Should return success (empty object), not throw
expect(result).toEqual({});
expect(mockSpinner.stop).toHaveBeenCalledWith('Test success');
});

it('should still throw when no success result was received before error', async () => {
// If we never got a success result, errors should propagate normally

function* mockGeneratorWithOnlyError() {
yield {
type: 'system',
subtype: 'init',
model: 'claude-opus-4-5-20251101',
tools: [],
mcp_servers: [],
};

// No success result, just an error
throw new Error('Actual SDK error');
}

mockQuery.mockReturnValue(mockGeneratorWithOnlyError());

await expect(
runAgent(
defaultAgentConfig,
'test prompt',
defaultOptions,
mockSpinner as unknown as ReturnType<typeof clack.spinner>,
{
successMessage: 'Test success',
errorMessage: 'Test error',
},
),
).rejects.toThrow('Actual SDK error');

expect(mockSpinner.stop).toHaveBeenCalledWith('Test error');
});

it('should not treat error results as success', async () => {
// A result with is_error: true should not count as success
// Even if subtype is 'success', the is_error flag takes precedence

function* mockGeneratorWithErrorResult() {
yield {
type: 'system',
subtype: 'init',
model: 'claude-opus-4-5-20251101',
tools: [],
mcp_servers: [],
};

yield {
type: 'result',
subtype: 'success', // subtype can be success but is_error true
is_error: true,
result: 'API Error: 500 Internal Server Error',
};

throw new Error('Process exited with code 1');
}

mockQuery.mockReturnValue(mockGeneratorWithErrorResult());

const result = await runAgent(
defaultAgentConfig,
'test prompt',
defaultOptions,
mockSpinner as unknown as ReturnType<typeof clack.spinner>,
{
successMessage: 'Test success',
errorMessage: 'Test error',
},
);

// Should return API error, not success
expect(result.error).toBe('WIZARD_API_ERROR');
expect(result.message).toContain('API Error');
});
});
});
28 changes: 28 additions & 0 deletions src/lib/agent-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@
import { LINTING_TOOLS } from './safe-tools';

// Dynamic import cache for ESM module
let _sdkModule: any = null;

Check warning on line 16 in src/lib/agent-interface.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
async function getSDKModule(): Promise<any> {

Check warning on line 17 in src/lib/agent-interface.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
if (!_sdkModule) {
_sdkModule = await import('@anthropic-ai/claude-agent-sdk');
}
return _sdkModule;

Check warning on line 21 in src/lib/agent-interface.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe return of an `any` typed value
}

/**
Expand All @@ -33,8 +33,8 @@

// Using `any` because typed imports from ESM modules require import attributes
// syntax which prettier cannot parse. See PR discussion for details.
type SDKMessage = any;

Check warning on line 36 in src/lib/agent-interface.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
type McpServersConfig = any;

Check warning on line 37 in src/lib/agent-interface.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type

export const AgentSignals = {
/** Signal emitted when the agent reports progress to the user */
Expand Down Expand Up @@ -306,7 +306,7 @@

const agentRunConfig: AgentRunConfig = {
workingDirectory: config.workingDirectory,
mcpServers,

Check warning on line 309 in src/lib/agent-interface.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe assignment of an `any` value
model: 'claude-opus-4-5-20251101',
};

Expand Down Expand Up @@ -362,7 +362,7 @@
errorMessage = 'Integration failed',
} = config ?? {};

const { query } = await getSDKModule();

Check warning on line 365 in src/lib/agent-interface.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe assignment of an `any` value

clack.log.step(
`This whole process should take about ${estimatedDurationMinutes} minutes including error checking and fixes.\n\nGrab some coffee!`,
Expand All @@ -377,6 +377,8 @@

const startTime = Date.now();
const collectedText: string[] = [];
// Track if we received a successful result (before any cleanup errors)
let receivedSuccessResult = false;

// Workaround for SDK bug: stdin closes before canUseTool responses can be sent.
// The fix is to use an async generator for the prompt that stays open until
Expand Down Expand Up @@ -417,7 +419,7 @@
'Skill',
];

const response = query({

Check warning on line 422 in src/lib/agent-interface.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe call of an `any` typed value

Check warning on line 422 in src/lib/agent-interface.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe assignment of an `any` value
prompt: createPromptStream(),
options: {
model: agentConfig.model,
Expand Down Expand Up @@ -454,6 +456,11 @@
handleSDKMessage(message, options, spinner, collectedText);
// Signal completion when result received
if (message.type === 'result') {
// Track successful results before any potential cleanup errors
// The SDK may emit a second error result during cleanup due to a race condition
if (message.subtype === 'success' && !message.is_error) {
receivedSuccessResult = true;
}
signalDone!();
}
}
Expand Down Expand Up @@ -500,6 +507,27 @@
// Signal done to unblock the async generator
signalDone!();

// If we already received a successful result, the error is from SDK cleanup
// This happens due to a race condition: the SDK tries to send a cleanup command
// after the prompt stream closes, but streaming mode is still active.
// See: https://github.com/anthropics/claude-agent-sdk-typescript/issues/41
if (receivedSuccessResult) {
const durationMs = Date.now() - startTime;
logToFile(
`Ignoring post-completion error, agent completed successfully in ${Math.round(
durationMs / 1000,
)}s`,
);
logToFile('Suppressed error:', (error as Error).message);
analytics.capture(WIZARD_INTERACTION_EVENT_NAME, {
action: 'agent integration completed',
duration_ms: durationMs,
duration_seconds: Math.round(durationMs / 1000),
});
spinner.stop(successMessage);
return {};
}

// Check if we collected an API error before the exception was thrown
const outputText = collectedText.join('\n');

Expand Down
Loading