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
60 changes: 59 additions & 1 deletion src/application/conversation-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert";
import { ConversationService, MAX_TEXT_LENGTH, isCorruptedHistoryError } from "./conversation-service.js";
import { ConversationService, MAX_TEXT_LENGTH, isCorruptedHistoryError, isTransientError } from "./conversation-service.js";
import { RateLimiter } from "./rate-limiter.js";
import type { Account } from "../domain/account.js";
import type { MessagingChannel, SelectionOption } from "../domain/ports/messaging-channel.port.js";
Expand Down Expand Up @@ -230,3 +230,61 @@ test("ConversationService · con historial, un saludo SÍ va al agente", async (

assert.strictEqual(runCalls, 1, "con contexto en curso, el saludo lo maneja el agente");
});

test("isTransientError", () => {
assert.ok(isTransientError("500 server_error"));
assert.ok(isTransientError("503 Service Unavailable overloaded"));
assert.ok(isTransientError("request failed: ETIMEDOUT"));
assert.ok(!isTransientError("400 bad request"));
assert.ok(!isTransientError("No tool call found for function call output"));
});

test("ConversationService · reintenta 1 vez ante error transitorio y responde", async () => {
const authStore = makeFakeAuthStore();
let calls = 0;
const service = new ConversationService(
{ getSession: () => makeFakeSession(), authStore, log: () => {} },
async () => {
calls += 1;
if (calls === 1) throw new Error("500 server_error temporal de OpenAI");
return { finalOutput: "ya te contesto" } as any;
},
);
const { channel, sent } = makeFakeChannel();
await service.handle({ account, chatId: "901", text: "consulta" }, channel);
assert.strictEqual(calls, 2, "reintenta una vez");
assert.strictEqual(sent.length, 1);
assert.match(sent[0].text, /ya te contesto/);
});

test("ConversationService · si el reintento también falla, avisa sin lanzar", async () => {
const authStore = makeFakeAuthStore();
let calls = 0;
const service = new ConversationService(
{ getSession: () => makeFakeSession(), authStore, log: () => {} },
async () => {
calls += 1;
throw new Error("503 overloaded");
},
);
const { channel, sent } = makeFakeChannel();
await service.handle({ account, chatId: "902", text: "consulta" }, channel); // no debe lanzar
assert.strictEqual(calls, 2);
assert.match(sent[0].text, /problema técnico temporal/);
});

test("ConversationService · error no transitorio: avisa sin reintentar ni lanzar", async () => {
const authStore = makeFakeAuthStore();
let calls = 0;
const service = new ConversationService(
{ getSession: () => makeFakeSession(), authStore, log: () => {} },
async () => {
calls += 1;
throw new Error("algo raro y no transitorio");
},
);
const { channel, sent } = makeFakeChannel();
await service.handle({ account, chatId: "903", text: "consulta" }, channel);
assert.strictEqual(calls, 1, "no reintenta");
assert.match(sent[0].text, /problema procesando/);
});
50 changes: 40 additions & 10 deletions src/application/conversation-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,19 @@ export function isCorruptedHistoryError(message: string): boolean {
);
}

/**
* Detecta errores transitorios (temporales) del run que merecen un reintento:
* 5xx / server_error de OpenAI, timeouts, cortes de red, sobrecarga.
*/
export function isTransientError(message: string): boolean {
return (
/server_error/i.test(message) ||
/\b(500|502|503|504)\b/.test(message) ||
/overloaded|temporarily unavailable|rate limit|too many requests/i.test(message) ||
/timeout|timed out|ETIMEDOUT|ECONNRESET|ECONNREFUSED|EAI_AGAIN|socket hang up|network/i.test(message)
);
}

/** true si la sesión no tiene historial todavía (conversación nueva). */
async function isEmptySession(session: ConversationStore): Promise<boolean> {
try {
Expand Down Expand Up @@ -130,15 +143,10 @@ export class ConversationService {
result = await this.run(apiAgent, userText, { context, session });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log({
kind: "error",
...logBase,
ms: Date.now() - startedAt,
error: message,
});
// Red de seguridad: si el historial quedó con un par de tool incompleto,
// OpenAI rechaza cada turno y el usuario queda bloqueado. Reiniciamos la
// sesión y le pedimos que repita, en vez de fallar en silencio.
log({ kind: "error", ...logBase, ms: Date.now() - startedAt, error: message });

// Historial con un par de tool incompleto: OpenAI rechaza cada turno y el
// usuario queda bloqueado. Reiniciamos la sesión y pedimos repetir.
if (isCorruptedHistoryError(message)) {
await Promise.resolve(
(session as { clearSession?: () => Promise<void> }).clearSession?.(),
Expand All @@ -149,7 +157,29 @@ export class ConversationService {
);
return;
}
throw error;

// Error transitorio (p. ej. 500 de OpenAI, timeout): reintentamos UNA vez.
// Antes esto se relanzaba y el usuario se quedaba sin respuesta (silencio).
if (isTransientError(message)) {
try {
result = await this.run(apiAgent, userText, { context, session });
} catch (retryError) {
const retryMessage = retryError instanceof Error ? retryError.message : String(retryError);
log({ kind: "error", ...logBase, ms: Date.now() - startedAt, error: `retry-failed: ${retryMessage}` });
await channel.sendText(
chatId,
"Perdona, estoy teniendo un problema técnico temporal. Inténtalo de nuevo en un momento, por favor.",
);
return;
}
} else {
// Cualquier otro error: nunca dejar al usuario en silencio.
await channel.sendText(
chatId,
"Perdona, ha habido un problema procesando tu mensaje. Inténtalo de nuevo, por favor.",
);
return;
}
}

if (context.authenticatedToken) {
Expand Down