diff --git a/src/message-runtime-rules.test.ts b/src/message-runtime-rules.test.ts index d8eba00..c5482d8 100644 --- a/src/message-runtime-rules.test.ts +++ b/src/message-runtime-rules.test.ts @@ -10,7 +10,16 @@ vi.mock('./config.js', async () => { }; }); +vi.mock('./service-routing.js', async () => { + const actual = + await vi.importActual( + './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( diff --git a/src/message-runtime-rules.ts b/src/message-runtime-rules.ts index ed30815..d718ba4 100644 --- a/src/message-runtime-rules.ts +++ b/src/message-runtime-rules.ts @@ -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) + ), ); } diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index f24ca32..1cb64e3 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -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(); diff --git a/src/message-turn-controller-failure-text.test.ts b/src/message-turn-controller-failure-text.test.ts new file mode 100644 index 0000000..cc71cc5 --- /dev/null +++ b/src/message-turn-controller-failure-text.test.ts @@ -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]'); + }); +}); diff --git a/src/message-turn-controller.ts b/src/message-turn-controller.ts index 4c0e29a..71f8fbe 100644 --- a/src/message-turn-controller.ts +++ b/src/message-turn-controller.ts @@ -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 | 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;