Skip to content

Commit 7572038

Browse files
danilocclaude
andauthored
fix: Resilience to Agent SDK cleanup burps (#231)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 95378d4 commit 7572038

File tree

2 files changed

+233
-11
lines changed

2 files changed

+233
-11
lines changed
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { runAgent } from '../agent-interface';
2+
import type { WizardOptions } from '../../utils/types';
3+
4+
// Mock dependencies
5+
jest.mock('../../utils/clack');
6+
jest.mock('../../utils/analytics');
7+
jest.mock('../../utils/debug');
8+
9+
// Mock the SDK module
10+
const mockQuery = jest.fn();
11+
jest.mock('@anthropic-ai/claude-agent-sdk', () => ({
12+
query: (...args: unknown[]) => mockQuery(...args),
13+
}));
14+
15+
// Get mocked clack for spinner
16+
import clack from '../../utils/clack';
17+
const mockClack = clack as jest.Mocked<typeof clack>;
18+
19+
describe('runAgent', () => {
20+
let mockSpinner: {
21+
start: jest.Mock;
22+
stop: jest.Mock;
23+
message: string;
24+
};
25+
26+
const defaultOptions: WizardOptions = {
27+
debug: false,
28+
installDir: '/test/dir',
29+
forceInstall: false,
30+
default: false,
31+
signup: false,
32+
localMcp: false,
33+
ci: false,
34+
};
35+
36+
const defaultAgentConfig = {
37+
workingDirectory: '/test/dir',
38+
mcpServers: {},
39+
model: 'claude-opus-4-5-20251101',
40+
};
41+
42+
beforeEach(() => {
43+
jest.clearAllMocks();
44+
45+
mockSpinner = {
46+
start: jest.fn(),
47+
stop: jest.fn(),
48+
message: '',
49+
};
50+
51+
mockClack.spinner = jest.fn().mockReturnValue(mockSpinner);
52+
mockClack.log = {
53+
step: jest.fn(),
54+
success: jest.fn(),
55+
error: jest.fn(),
56+
warn: jest.fn(),
57+
warning: jest.fn(),
58+
info: jest.fn(),
59+
message: jest.fn(),
60+
};
61+
});
62+
63+
describe('race condition handling', () => {
64+
it('should return success when agent completes successfully then SDK cleanup fails', async () => {
65+
// This simulates the race condition:
66+
// 1. Agent completes with success result
67+
// 2. signalDone() is called, completing the prompt generator
68+
// 3. SDK tries to send cleanup command while streaming is active
69+
// 4. SDK throws an error
70+
// The fix should recognize we already got a success and return success anyway
71+
72+
function* mockGeneratorWithCleanupError() {
73+
yield {
74+
type: 'system',
75+
subtype: 'init',
76+
model: 'claude-opus-4-5-20251101',
77+
tools: [],
78+
mcp_servers: [],
79+
};
80+
81+
yield {
82+
type: 'result',
83+
subtype: 'success',
84+
is_error: false,
85+
result: 'Agent completed successfully',
86+
};
87+
88+
// Simulate the SDK cleanup error that occurs after success
89+
throw new Error('only prompt commands are supported in streaming mode');
90+
}
91+
92+
mockQuery.mockReturnValue(mockGeneratorWithCleanupError());
93+
94+
const result = await runAgent(
95+
defaultAgentConfig,
96+
'test prompt',
97+
defaultOptions,
98+
mockSpinner as unknown as ReturnType<typeof clack.spinner>,
99+
{
100+
successMessage: 'Test success',
101+
errorMessage: 'Test error',
102+
},
103+
);
104+
105+
// Should return success (empty object), not throw
106+
expect(result).toEqual({});
107+
expect(mockSpinner.stop).toHaveBeenCalledWith('Test success');
108+
});
109+
110+
it('should still throw when no success result was received before error', async () => {
111+
// If we never got a success result, errors should propagate normally
112+
113+
function* mockGeneratorWithOnlyError() {
114+
yield {
115+
type: 'system',
116+
subtype: 'init',
117+
model: 'claude-opus-4-5-20251101',
118+
tools: [],
119+
mcp_servers: [],
120+
};
121+
122+
// No success result, just an error
123+
throw new Error('Actual SDK error');
124+
}
125+
126+
mockQuery.mockReturnValue(mockGeneratorWithOnlyError());
127+
128+
await expect(
129+
runAgent(
130+
defaultAgentConfig,
131+
'test prompt',
132+
defaultOptions,
133+
mockSpinner as unknown as ReturnType<typeof clack.spinner>,
134+
{
135+
successMessage: 'Test success',
136+
errorMessage: 'Test error',
137+
},
138+
),
139+
).rejects.toThrow('Actual SDK error');
140+
141+
expect(mockSpinner.stop).toHaveBeenCalledWith('Test error');
142+
});
143+
144+
it('should not treat error results as success', async () => {
145+
// A result with is_error: true should not count as success
146+
// Even if subtype is 'success', the is_error flag takes precedence
147+
148+
function* mockGeneratorWithErrorResult() {
149+
yield {
150+
type: 'system',
151+
subtype: 'init',
152+
model: 'claude-opus-4-5-20251101',
153+
tools: [],
154+
mcp_servers: [],
155+
};
156+
157+
yield {
158+
type: 'result',
159+
subtype: 'success', // subtype can be success but is_error true
160+
is_error: true,
161+
result: 'API Error: 500 Internal Server Error',
162+
};
163+
164+
throw new Error('Process exited with code 1');
165+
}
166+
167+
mockQuery.mockReturnValue(mockGeneratorWithErrorResult());
168+
169+
const result = await runAgent(
170+
defaultAgentConfig,
171+
'test prompt',
172+
defaultOptions,
173+
mockSpinner as unknown as ReturnType<typeof clack.spinner>,
174+
{
175+
successMessage: 'Test success',
176+
errorMessage: 'Test error',
177+
},
178+
);
179+
180+
// Should return API error, not success
181+
expect(result.error).toBe('WIZARD_API_ERROR');
182+
expect(result.message).toContain('API Error');
183+
});
184+
});
185+
});

src/lib/agent-interface.ts

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,8 @@ export function initializeAgent(
288288
const gatewayUrl = getLlmGatewayUrlFromHost(config.posthogApiHost);
289289
process.env.ANTHROPIC_BASE_URL = gatewayUrl;
290290
process.env.ANTHROPIC_AUTH_TOKEN = config.posthogApiKey;
291+
// Use CLAUDE_CODE_OAUTH_TOKEN to override any stored /login credentials
292+
process.env.CLAUDE_CODE_OAUTH_TOKEN = config.posthogApiKey;
291293
// Disable experimental betas (like input_examples) that the LLM gateway doesn't support
292294
process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = 'true';
293295

@@ -377,6 +379,8 @@ export async function runAgent(
377379

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

381385
// Workaround for SDK bug: stdin closes before canUseTool responses can be sent.
382386
// The fix is to use an async generator for the prompt that stays open until
@@ -398,6 +402,31 @@ export async function runAgent(
398402
await resultReceived;
399403
};
400404

405+
// Helper to handle successful completion (used in normal path and race condition recovery)
406+
const completeWithSuccess = (
407+
suppressedError?: Error,
408+
): { error?: AgentErrorType; message?: string } => {
409+
const durationMs = Date.now() - startTime;
410+
const durationSeconds = Math.round(durationMs / 1000);
411+
412+
if (suppressedError) {
413+
logToFile(
414+
`Ignoring post-completion error, agent completed successfully in ${durationSeconds}s`,
415+
);
416+
logToFile('Suppressed error:', suppressedError.message);
417+
} else {
418+
logToFile(`Agent run completed in ${durationSeconds}s`);
419+
}
420+
421+
analytics.capture(WIZARD_INTERACTION_EVENT_NAME, {
422+
action: 'agent integration completed',
423+
duration_ms: durationMs,
424+
duration_seconds: durationSeconds,
425+
});
426+
spinner.stop(successMessage);
427+
return {};
428+
};
429+
401430
try {
402431
// Tools needed for the wizard:
403432
// - File operations: Read, Write, Edit
@@ -428,7 +457,11 @@ export async function runAgent(
428457
settingSources: ['project'],
429458
// Explicitly enable required tools including Skill
430459
allowedTools,
431-
env: { ...process.env },
460+
env: {
461+
...process.env,
462+
// Prevent user's Anthropic API key from overriding the wizard's OAuth token
463+
ANTHROPIC_API_KEY: undefined,
464+
},
432465
canUseTool: (toolName: string, input: unknown) => {
433466
logToFile('canUseTool called:', { toolName, input });
434467
const result = wizardCanUseTool(
@@ -454,11 +487,15 @@ export async function runAgent(
454487
handleSDKMessage(message, options, spinner, collectedText);
455488
// Signal completion when result received
456489
if (message.type === 'result') {
490+
// Track successful results before any potential cleanup errors
491+
// The SDK may emit a second error result during cleanup due to a race condition
492+
if (message.subtype === 'success' && !message.is_error) {
493+
receivedSuccessResult = true;
494+
}
457495
signalDone!();
458496
}
459497
}
460498

461-
const durationMs = Date.now() - startTime;
462499
const outputText = collectedText.join('\n');
463500

464501
// Check for error markers in the agent's output
@@ -487,19 +524,19 @@ export async function runAgent(
487524
return { error: AgentErrorType.API_ERROR, message: outputText };
488525
}
489526

490-
logToFile(`Agent run completed in ${Math.round(durationMs / 1000)}s`);
491-
analytics.capture(WIZARD_INTERACTION_EVENT_NAME, {
492-
action: 'agent integration completed',
493-
duration_ms: durationMs,
494-
duration_seconds: Math.round(durationMs / 1000),
495-
});
496-
497-
spinner.stop(successMessage);
498-
return {};
527+
return completeWithSuccess();
499528
} catch (error) {
500529
// Signal done to unblock the async generator
501530
signalDone!();
502531

532+
// If we already received a successful result, the error is from SDK cleanup
533+
// This happens due to a race condition: the SDK tries to send a cleanup command
534+
// after the prompt stream closes, but streaming mode is still active.
535+
// See: https://github.com/anthropics/claude-agent-sdk-typescript/issues/41
536+
if (receivedSuccessResult) {
537+
return completeWithSuccess(error as Error);
538+
}
539+
503540
// Check if we collected an API error before the exception was thrown
504541
const outputText = collectedText.join('\n');
505542

0 commit comments

Comments
 (0)