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
30 changes: 26 additions & 4 deletions packages/core/src/orchestrator/orchestrator-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -954,8 +954,19 @@ async function handleStreamMode(
if (!commandDetected && platform.sendStructuredEvent) {
await platform.sendStructuredEvent(conversationId, msg);
}
} else if (msg.type === 'result' && msg.sessionId) {
newSessionId = msg.sessionId;
} else if (msg.type === 'result') {
if (msg.sessionId) {
newSessionId = msg.sessionId;
}
if (msg.isError) {
getLog().warn({ conversationId, errorSubtype: msg.errorSubtype }, 'ai_result_error');
const syntheticError = new Error(msg.errorSubtype ?? 'AI result error');
await platform.sendMessage(conversationId, classifyAndFormatError(syntheticError));
if (newSessionId) {
await tryPersistSessionId(session.id, newSessionId);
}
return;
}
if (!commandDetected && platform.sendStructuredEvent) {
await platform.sendStructuredEvent(conversationId, msg);
}
Expand Down Expand Up @@ -1066,8 +1077,19 @@ async function handleBatchMode(
allChunks.push({ type: 'tool', content: toolMessage });
getLog().debug({ toolName: msg.toolName }, 'tool_call');
}
} else if (msg.type === 'result' && msg.sessionId) {
newSessionId = msg.sessionId;
} else if (msg.type === 'result') {
if (msg.sessionId) {
newSessionId = msg.sessionId;
}
if (msg.isError) {
getLog().warn({ conversationId, errorSubtype: msg.errorSubtype }, 'ai_result_error');
const syntheticError = new Error(msg.errorSubtype ?? 'AI result error');
await platform.sendMessage(conversationId, classifyAndFormatError(syntheticError));
if (newSessionId) {
await tryPersistSessionId(session.id, newSessionId);
}
return;
}
}

if (!commandDetected && allChunks.length > MAX_BATCH_TOTAL_CHUNKS) {
Expand Down
107 changes: 97 additions & 10 deletions packages/core/src/utils/error-formatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,97 @@ describe('classifyAndFormatError', () => {
});
});

describe('authentication errors', () => {
describe('Claude OAuth refresh-token errors', () => {
test('detects "refresh token" in message', () => {
const result = classifyAndFormatError(new Error('Your refresh token was already used'));
expect(result).toContain('Claude authentication expired');
expect(result).toContain('/login');
});

test('detects "could not be refreshed" in message', () => {
const result = classifyAndFormatError(new Error('Your access token could not be refreshed'));
expect(result).toContain('Claude authentication expired');
});

test('detects "log out and sign in" in message', () => {
const result = classifyAndFormatError(new Error('Please log out and sign in again'));
expect(result).toContain('Claude authentication expired');
});

test('detects "OAuth token has expired" in message', () => {
const result = classifyAndFormatError(
new Error('API Error: 401 OAuth token has expired. Please run /login')
);
expect(result).toContain('Claude authentication expired');
expect(result).toContain('claude logout && claude login');
});

test('detects "sign-in has expired" in message', () => {
const result = classifyAndFormatError(
new Error('Unable to start session: sign-in has expired')
);
expect(result).toContain('Claude authentication expired');
});

test('handles full Claude OAuth error with refresh token race condition', () => {
const result = classifyAndFormatError(
new Error(
'Claude Code auth error: Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.'
)
);
expect(result).toContain('Claude authentication expired');
});
});

describe('Claude general auth errors', () => {
test('detects "Claude Code auth error:" prefix for non-OAuth errors', () => {
const result = classifyAndFormatError(new Error('Claude Code auth error: 403 forbidden'));
expect(result).toContain('Claude authentication error');
expect(result).toContain('/login');
});
});

describe('Codex auth errors', () => {
test('detects Codex 401 retry exhaustion', () => {
const result = classifyAndFormatError(
new Error('Codex query failed: exceeded retry limit, last status: 401 Unauthorized')
);
expect(result).toContain('Codex authentication error');
expect(result).toContain('codex login');
});

test('detects Codex query failed with Unauthorized', () => {
const result = classifyAndFormatError(new Error('Codex query failed: Unauthorized'));
expect(result).toContain('Codex authentication error');
expect(result).toContain('codex login');
});
});

describe('general authentication errors', () => {
test('detects "API key" in message', () => {
const result = classifyAndFormatError(new Error('Invalid API key provided'));
expect(result).toBe('⚠️ AI service authentication error. Please check configuration.');
expect(result).toContain('authentication error');
});

test('detects "authentication_error" in message', () => {
const result = classifyAndFormatError(new Error('authentication_error: invalid'));
expect(result).toContain('authentication error');
});

test('detects "authentication" in message', () => {
const result = classifyAndFormatError(new Error('authentication failed'));
expect(result).toBe('⚠️ AI service authentication error. Please check configuration.');
test('detects "authentication error" in message', () => {
const result = classifyAndFormatError(new Error('authentication error'));
expect(result).toContain('authentication error');
});

test('detects "401" in message', () => {
const result = classifyAndFormatError(new Error('HTTP 401 Unauthorized'));
expect(result).toBe('⚠️ AI service authentication error. Please check configuration.');
expect(result).toContain('authentication error');
});

test('detects 401 as standalone in message', () => {
const result = classifyAndFormatError(new Error('Status: 401'));
expect(result).toBe('⚠️ AI service authentication error. Please check configuration.');
test('does not false-positive on generic messages containing "auth"', () => {
// "auth" alone should NOT match — only specific patterns
const result = classifyAndFormatError(new Error('author name missing'));
expect(result).not.toContain('authentication');
});
});

Expand Down Expand Up @@ -232,9 +304,24 @@ describe('classifyAndFormatError', () => {
expect(result).toBe('⚠️ AI rate limit reached. Please wait a moment and try again.');
});

test('Claude OAuth check takes precedence over general auth check', () => {
// Contains both "refresh token" and "Claude Code auth error:" — OAuth branch fires first
const result = classifyAndFormatError(
new Error('Claude Code auth error: refresh token expired')
);
expect(result).toContain('Claude authentication expired');
});

test('Codex auth takes precedence over generic Codex error handler', () => {
// Contains "Codex query failed:" AND "401" — Codex auth branch fires first
const result = classifyAndFormatError(new Error('Codex query failed: 401 Unauthorized'));
expect(result).toContain('Codex authentication error');
expect(result).toContain('codex login');
});

test('auth check takes precedence over short-message fallback', () => {
const result = classifyAndFormatError(new Error('API key'));
expect(result).toBe('⚠️ AI service authentication error. Please check configuration.');
expect(result).toContain('authentication error');
});

test('Codex check is applied before generic fallback', () => {
Expand Down
35 changes: 32 additions & 3 deletions packages/core/src/utils/error-formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,42 @@ export function classifyAndFormatError(error: Error): string {
return '⚠️ AI rate limit reached. Please wait a moment and try again.';
}

// AI/SDK errors - authentication
// Claude-specific auth errors — OAuth token refresh failures
// These come from Claude Code subprocess stderr or SDK result subtypes.
// Recovery: `/login` in-session or `claude logout && claude login` in terminal.
if (
message.includes('refresh token') ||
message.includes('could not be refreshed') ||
message.includes('log out and sign in') ||
message.includes('OAuth token has expired') ||
message.includes('sign-in has expired')
) {
return '⚠️ Claude authentication expired. Run `/login` inside Claude Code or `claude logout && claude login` in your terminal.';
}

// Claude-specific auth errors — general (subprocess crash with auth classification)
if (message.startsWith('Claude Code auth error:')) {
return '⚠️ Claude authentication error. Run `/login` inside Claude Code or check your API key configuration.';
}

// Codex-specific auth errors — 401 retry exhaustion
// Codex surfaces auth failures as "exceeded retry limit, last status: 401 Unauthorized"
// Recovery: `codex login` in terminal.
if (
message.includes('Codex query failed:') &&
(message.includes('401') || message.includes('Unauthorized'))
) {
return '⚠️ Codex authentication error. Run `codex login` in your terminal to re-authenticate.';
}

// General AI/SDK authentication errors
if (
message.includes('API key') ||
message.includes('authentication') ||
message.includes('authentication_error') ||
message.includes('authentication error') ||
message.includes('401')
) {
return '⚠️ AI service authentication error. Please check configuration.';
return '⚠️ AI service authentication error. Please check your API key or credentials.';
}

// Network errors - timeout
Expand Down
Loading