Show the error reason after the generic failure message

When an agent turn fails, users only saw "요청을 완료하지 못했습니다. 다시
시도해 주세요." with no clue why. Capture the failure reason (explicit
error field, or provider-error text like "API Error: 529 Overloaded"
classified by detectClaudeProviderFailureMessage) in the turn controller
and append it to the failure message ("...\n\n오류 내용: <reason>",
secret-redacted + length-capped). Update the paired-room loop filter to
startsWith so a failure-with-reason is still recognized and not
re-injected into prompt history. Unit-tested.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-08-24 17:10:18 +09:00
parent b9d4144b7e
commit b3c5a4b41b
5 changed files with 127 additions and 3 deletions

View File

@@ -10,7 +10,16 @@ vi.mock('./config.js', async () => {
};
});
vi.mock('./service-routing.js', async () => {
const actual =
await vi.importActual<typeof import('./service-routing.js')>(
'./service-routing.js',
);
return { ...actual, hasReviewerLease: vi.fn(() => true) };
});
import {
filterLoopingPairedBotMessages,
matchesExpectedPairedFollowUpIntent,
resolveFollowUpDispatch,
resolveExecutionTarget,
@@ -19,6 +28,7 @@ import {
resolveQueuedTurnRole,
resolveSessionFolder,
} from './message-runtime-rules.js';
import { appendErrorDetailToFailureText } from './message-turn-controller.js';
import {
resolveLeaseServiceId,
type EffectiveChannelLease,
@@ -38,6 +48,33 @@ const baseLease: EffectiveChannelLease = {
explicit: true,
};
describe('filterLoopingPairedBotMessages', () => {
const FAIL = '요청을 완료하지 못했습니다. 다시 시도해 주세요.';
const msg = (content: string, isBot: boolean) =>
({ content, is_bot_message: isBot }) as never;
it('filters both the plain failure and a failure that carries an error reason', () => {
const detailed = appendErrorDetailToFailureText(
FAIL,
'API Error: 529 Overloaded',
);
const out = filterLoopingPairedBotMessages(
'chat-1',
[
msg(FAIL, true),
msg(detailed, true),
msg('진짜 사용자 메시지', false),
msg('GPU 상태 정상입니다', true),
],
FAIL,
);
expect(out.map((m) => (m as { content: string }).content)).toEqual([
'진짜 사용자 메시지',
'GPU 상태 정상입니다',
]);
});
});
describe('message-runtime-rules', () => {
it('maps review_ready to a reviewer turn', () => {
expect(

View File

@@ -544,8 +544,13 @@ export function filterLoopingPairedBotMessages(
) {
if (!hasReviewerLease(chatJid)) return messages;
// startsWith (not exact) so a failure message that carries an appended error
// reason ("...다시 시도해 주세요.\n\n오류 내용: ...") is still recognized as the
// generic failure and filtered out of paired-room prompt history.
return messages.filter(
(message) =>
!(message.is_bot_message && message.content.trim() === failureText),
!(
message.is_bot_message && message.content.trim().startsWith(failureText)
),
);
}

View File

@@ -5575,7 +5575,7 @@ describe('createMessageRuntime', () => {
expect(channel.editMessage).toHaveBeenCalledWith(
chatJid,
'progress-1',
'요청을 완료하지 못했습니다. 다시 시도해 주세요.',
'요청을 완료하지 못했습니다. 다시 시도해 주세요.\n\n오류 내용: temporary failure',
);
expect(lastAgentTimestamps[chatJid]).toBe('1');
expect(saveState).toHaveBeenCalled();

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { appendErrorDetailToFailureText } from './message-turn-controller.js';
const BASE = '요청을 완료하지 못했습니다. 다시 시도해 주세요.';
describe('appendErrorDetailToFailureText', () => {
it('returns the base text unchanged when there is no detail', () => {
expect(appendErrorDetailToFailureText(BASE, null)).toBe(BASE);
expect(appendErrorDetailToFailureText(BASE, undefined)).toBe(BASE);
expect(appendErrorDetailToFailureText(BASE, ' ')).toBe(BASE);
});
it('appends the error reason so the user sees what went wrong', () => {
const out = appendErrorDetailToFailureText(
BASE,
'API Error: 529 Overloaded. This is a server-side issue.',
);
expect(out.startsWith(BASE)).toBe(true);
expect(out).toContain('오류 내용:');
expect(out).toContain('API Error: 529 Overloaded');
});
it('collapses whitespace and caps very long detail', () => {
const long = 'API Error: ' + 'x'.repeat(500);
const out = appendErrorDetailToFailureText(BASE, long);
expect(out).toContain('…');
// base + "\n\n오류 내용: " + 300 chars + "…"
expect(out.length).toBeLessThan(BASE.length + 340);
expect(appendErrorDetailToFailureText(BASE, 'a\n\n b\t c')).toContain(
'오류 내용: a b c',
);
});
it('redacts secrets embedded in the error detail', () => {
const out = appendErrorDetailToFailureText(
BASE,
'auth failed with key sk-ant-api03-ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
);
expect(out).not.toContain('sk-ant-api03-ABCDEFGHIJKLMNOPQRSTUVWXYZ');
expect(out).toContain('[REDACTED]');
});
});

View File

@@ -3,6 +3,7 @@ import {
getAgentOutputAttachments,
getAgentOutputText,
} from './agent-output.js';
import { detectClaudeProviderFailureMessage } from './agent-error-detection.js';
import { createScopedLogger, logger } from './logger.js';
import { sanitizeForOutbound } from './router.js';
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
@@ -23,6 +24,25 @@ import {
export type { VisiblePhase };
/**
* Append a concise error reason to the generic failure message so the user can
* see, at a glance, WHY the turn failed (e.g. an Anthropic "529 Overloaded").
* The detail is secret-redacted + whitespace-collapsed + length-capped. Returns
* the base text unchanged when there is no usable detail.
*/
export function appendErrorDetailToFailureText(
base: string,
detail: string | null | undefined,
): string {
const cleaned = detail
? sanitizeForOutbound(detail).replace(/\s+/g, ' ').trim()
: '';
if (!cleaned) return base;
const MAX = 300;
const shown = cleaned.length > MAX ? `${cleaned.slice(0, MAX)}…` : cleaned;
return `${base}\n\n오류 내용: ${shown}`;
}
interface SubagentTrack {
label: string;
activities: string[];
@@ -59,6 +79,7 @@ export class MessageTurnController {
private idleTimer: ReturnType<typeof setTimeout> | null = null;
private visiblePhase: VisiblePhase = 'silent';
private hadError = false;
private lastErrorDetail: string | null = null;
private producedDeliverySucceeded = true;
private latestProgressText: string | null = null;
private previousProgressText: string | null = null;
@@ -175,6 +196,19 @@ export class MessageTurnController {
const text = raw ? sanitizeForOutbound(raw) : null;
const attachments = getAgentOutputAttachments(result);
// Remember the most recent failure REASON so the generic failure message
// can tell the user WHAT went wrong (e.g. "API Error: 529 Overloaded").
// Only capture a genuine error reason — an explicit `error` field, or text
// the classifier recognizes as a provider error (429 / overloaded / network)
// that the SDK surfaces as a success-typed result. Do NOT capture arbitrary
// progress text that merely carries an error status.
const explicitError = result.error?.trim();
if (explicitError) {
this.lastErrorDetail = explicitError;
} else if (raw && detectClaudeProviderFailureMessage(raw)) {
this.lastErrorDetail = raw.trim();
}
if (raw) {
this.log.info(
{
@@ -820,7 +854,12 @@ export class MessageTurnController {
if (this.terminalObserved()) {
return;
}
await this.publishTerminalText(this.options.failureFinalText);
await this.publishTerminalText(
appendErrorDetailToFailureText(
this.options.failureFinalText,
this.lastErrorDetail,
),
);
}
private requestAgentClose(reason: string): void {
if (this.closeRequested) return;