diff --git a/src/bot/commands/ask.history.test.ts b/src/bot/commands/ask.history.test.ts index 378fa0df..79834e6b 100644 --- a/src/bot/commands/ask.history.test.ts +++ b/src/bot/commands/ask.history.test.ts @@ -74,7 +74,10 @@ class FakeAgent { return agentRunMock(question, history); } } -mock.module('../../services/ai/agent', () => ({ ExpenseBotAgent: FakeAgent })); +mock.module('../../services/ai/agent', () => ({ + ExpenseBotAgent: FakeAgent, + UNCLASSIFIED_FAILURE_MESSAGE: '❌ Ошибка при обработке вопроса. Попробуй еще раз.', +})); mock.module('../../services/analytics/spending-analytics', () => ({ spendingAnalytics: { getFinancialSnapshot: mock(() => ({})) }, })); diff --git a/src/bot/commands/ask.ts b/src/bot/commands/ask.ts index f524a5a5..a62c0537 100644 --- a/src/bot/commands/ask.ts +++ b/src/bot/commands/ask.ts @@ -9,7 +9,7 @@ import { database } from '../../database'; import type { Group, User } from '../../database/types'; import { AgentError } from '../../errors'; import { validateAdvice } from '../../services/ai/advice-validator'; -import { ExpenseBotAgent } from '../../services/ai/agent'; +import { ExpenseBotAgent, UNCLASSIFIED_FAILURE_MESSAGE } from '../../services/ai/agent'; import { aiStreamRound, stripThinkingTags } from '../../services/ai/streaming'; import { NO_ANSWER_MESSAGE } from '../../services/ai/telegram-stream'; import type { AgentContext } from '../../services/ai/types'; @@ -265,9 +265,8 @@ async function handleAskWithAnthropic( return false; } logger.error({ err: error }, '[ASK] Anthropic agent error'); - const failureMessage = '❌ Ошибка при обработке вопроса. Попробуй еще раз.'; - await sendMessage(failureMessage); - persistAssistantReply(failureMessage); + await sendMessage(UNCLASSIFIED_FAILURE_MESSAGE); + persistAssistantReply(UNCLASSIFIED_FAILURE_MESSAGE); return false; } } diff --git a/src/errors/service-errors.ts b/src/errors/service-errors.ts index 9db9d004..2a541437 100644 --- a/src/errors/service-errors.ts +++ b/src/errors/service-errors.ts @@ -21,7 +21,11 @@ export class OAuthError extends AppError {} /** AI agent error — user-facing message already sent to Telegram, no further action needed */ export class AgentError extends AppError { - constructor(public readonly userMessage: string) { - super(userMessage, 'AGENT_ERROR'); + /** `cause` keeps the original error (e.g. an unclassified non-AI one) for the logs. */ + constructor( + public readonly userMessage: string, + cause?: unknown, + ) { + super(userMessage, 'AGENT_ERROR', cause); } } diff --git a/src/services/ai/agent.errors.test.ts b/src/services/ai/agent.errors.test.ts index b10add19..054d3a1f 100644 --- a/src/services/ai/agent.errors.test.ts +++ b/src/services/ai/agent.errors.test.ts @@ -505,5 +505,28 @@ describe('ExpenseBotAgent', () => { expect(shown).toContain('Записываю расход: 5 EUR, Еда'); expect(shown.endsWith('⏳ Слишком много запросов к AI. Подожди минуту.')).toBe(true); }); + + it('a write tool, then an unclassified non-AI error: the work log stays, failure under it', async () => { + mockIsRetryableError.mockReturnValue(false); + mockClassifyAiError.mockReturnValue(null); + const bug = new TypeError('bug after the write'); + mockToolCallRound('add_expense', '{"amount":5,"currency":"EUR","category":"Еда"}'); + mockAiStreamRound.mockRejectedValueOnce(bug); + + const { AgentError } = await import('../../errors'); + let thrown: unknown; + await agent.run('запиши 5 евро еда', [], mockBot as unknown as Bot).catch((e) => { + thrown = e; + }); + + // The caller must not send a second, duplicate failure message: the agent already + // rendered it under the preserved work log. + expect(thrown).toBeInstanceOf(AgentError); + expect(mockBot.api.deleteMessage).not.toHaveBeenCalled(); + const edits = mockBot.api.editMessageText.mock.calls as unknown as Array<[{ text: string }]>; + const shown = edits.at(-1)?.[0].text ?? ''; + expect(shown).toContain('Записываю расход: 5 EUR, Еда'); + expect(shown).toContain('Ошибка при обработке вопроса'); + }); }); }); diff --git a/src/services/ai/agent.ts b/src/services/ai/agent.ts index 49a5d92f..db80bfe0 100644 --- a/src/services/ai/agent.ts +++ b/src/services/ai/agent.ts @@ -60,6 +60,9 @@ import type { AgentContext, ToolCallResult, ToolResult } from './types'; const logger = createLogger('agent'); +/** Failure text for a non-AI error mid-run; ask.ts shows the same one when it owns the reply. */ +export const UNCLASSIFIED_FAILURE_MESSAGE = '❌ Ошибка при обработке вопроса. Попробуй еще раз.'; + /** Singleton debug logger — writes full AI conversations to logs/chats/ when enabled */ export const aiDebugLogger = new AiDebugLogger(env.AI_DEBUG_LOGS, 'logs'); @@ -360,9 +363,21 @@ export class ExpenseBotAgent { debugCtx?.flush(); this.recordTrajectory('error', classification?.kind ?? 'unclassified'); if (!classification) { - // Not a recognizable AI/network/timeout failure — propagate so the caller - // (and logs) see the real, unrelated error instead of a masked generic one. + // Not a recognizable AI/network/timeout failure — a bug or an unrelated error. // (No admin AI-failure report here: it isn't an AI failure.) + if (this.writeToolRan) { + // A write tool already changed data: the work log is the user's only record of + // what was done, so keep it and show the failure under it (issue #182). The + // AgentError tells the caller the message is already on screen — no duplicate. + logger.error( + { err: error }, + '[AGENT] non-AI error after write tools — keeping the work log, failure under it', + ); + await writer.fail(UNCLASSIFIED_FAILURE_MESSAGE); + throw new AgentError(UNCLASSIFIED_FAILURE_MESSAGE, error); + } + // Nothing user-visible was done — propagate so the caller (and logs) see the + // real, unrelated error instead of a masked generic one. await writer.deleteSentMessage(); throw error; }