From 4662e4a8282f8cd55ed4ab602e67d0e8c9e411ab Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 02:15:12 +0000 Subject: [PATCH] fix(handoff): reutilizar el ticket abierto en vez de duplicarlo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Si el cliente pide humano otra vez, handoffHuman ya no abre un ticket nuevo ni vuelve a avisar al dueño. open_ticket_id se queda en el ticket que sigue abierto. Ticket #09A022. Co-authored-by: santmun --- src/db/tickets.ts | 14 +++++ src/tools/handoffHuman.ts | 14 ++++- test/tools/handoffHuman.test.ts | 95 ++++++++++++++++++++++++++++++++- 3 files changed, 120 insertions(+), 3 deletions(-) diff --git a/src/db/tickets.ts b/src/db/tickets.ts index 62ee2cc7..3dc22190 100644 --- a/src/db/tickets.ts +++ b/src/db/tickets.ts @@ -36,6 +36,20 @@ export class TicketsRepo { return this.db.first("SELECT * FROM tickets WHERE id = ?", [id]); } + /** + * Ticket aún abierto (open o in_progress) de esta conversación, el más reciente. + * Sirve para no abrir otro cuando el cliente repite el pedido de humano. + */ + async findOpenByConversation(conversationId: string): Promise { + return this.db.first( + `SELECT * FROM tickets + WHERE conversation_id = ? AND status != 'resolved' + ORDER BY created_at DESC + LIMIT 1`, + [conversationId], + ); + } + async listOpen(): Promise { return this.db.all( "SELECT * FROM tickets WHERE status != 'resolved' ORDER BY created_at DESC", diff --git a/src/tools/handoffHuman.ts b/src/tools/handoffHuman.ts index 451334db..fb70c6a8 100644 --- a/src/tools/handoffHuman.ts +++ b/src/tools/handoffHuman.ts @@ -10,7 +10,7 @@ import { isPro } from "../config"; export function handoffHumanTool(env: Env, getConversationId: () => string | null) { return tool({ description: - "Crea un ticket para el dueño + le manda email. Usalo cuando el bot no puede resolver o el cliente pide humano explícitamente.", + "Crea un ticket para el dueño + le manda email. Usalo cuando el bot no puede resolver o el cliente pide humano explícitamente. Si esa conversación ya tiene un ticket abierto, reutiliza ese y no avisa otra vez.", inputSchema: z.object({ reason: z.string().describe("Categoría corta del problema"), summary: z.string().max(300).describe("Resumen en 1 frase del contexto"), @@ -20,6 +20,18 @@ export function handoffHumanTool(env: Env, getConversationId: () => string | nul const convId = getConversationId(); const db = new Db(env.DB); const tickets = new TicketsRepo(db); + + // El cliente que insiste ("quiero una persona" varias veces) no abre un + // ticket nuevo ni vuelve a avisar al dueño. open_ticket_id queda en el + // ticket que sigue abierto, no en un duplicado. + if (convId) { + const existing = await tickets.findOpenByConversation(convId); + if (existing) { + await new ConversationsRepo(db).setOpenTicket(convId, existing.id); + return { ticketId: existing.id }; + } + } + const ticketId = await tickets.create({ conversationId: convId, category, diff --git a/test/tools/handoffHuman.test.ts b/test/tools/handoffHuman.test.ts index 557e899f..9bfa53b9 100644 --- a/test/tools/handoffHuman.test.ts +++ b/test/tools/handoffHuman.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { createTestMiniflare } from "../helpers/miniflareSetup"; import { Db } from "../../src/db/client"; import { TicketsRepo } from "../../src/db/tickets"; @@ -7,16 +7,22 @@ import { handoffHumanTool } from "../../src/tools/handoffHuman"; let env: any; let tickets: TicketsRepo; +let convs: ConversationsRepo; let convId: string; +afterEach(() => { + vi.restoreAllMocks(); +}); + beforeEach(async () => { const mf = await createTestMiniflare(); const d1 = await mf.getD1Database("DB"); const db = new Db(d1 as any); tickets = new TicketsRepo(db); + convs = new ConversationsRepo(db); // The tickets table FKs conversation_id -> conversations(id), so we need a // real conversation row before the tool can attach a ticket to it. - const conv = await new ConversationsRepo(db).getOrCreate("telegram", "u1"); + const conv = await convs.getOrCreate("telegram", "u1"); convId = conv.id; env = { DB: d1, @@ -45,4 +51,89 @@ describe("handoffHumanTool", () => { expect(list).toHaveLength(1); expect(list[0].summary).toContain("María"); }); + + it("reuses the open ticket and notifies the owner only once when the customer repeats the request", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("{}", { status: 200 })); + const envTelegram = { + ...env, + RESEND_API_KEY: undefined, + TELEGRAM_BOT_TOKEN: "123:abc", + OWNER_TELEGRAM_CHAT_ID: "99", + }; + const tool = handoffHumanTool(envTelegram, () => convId); + const args = { + reason: "humano", + summary: "quiere hablar con una persona", + category: "product" as const, + }; + + const first = await tool.execute!(args, {} as any); + const second = await tool.execute!(args, {} as any); + const third = await tool.execute!( + { ...args, summary: "insiste, que lo comuniquen ya" }, + {} as any, + ); + + const ticketId = (first as { ticketId: string }).ticketId; + expect((second as { ticketId: string }).ticketId).toBe(ticketId); + expect((third as { ticketId: string }).ticketId).toBe(ticketId); + + const list = await tickets.listOpen(); + expect(list).toHaveLength(1); + expect(list[0].id).toBe(ticketId); + + const conv = await convs.getById(convId); + expect(conv?.open_ticket_id).toBe(ticketId); + + const telegramCalls = fetchSpy.mock.calls.filter((c) => + String(c[0]).includes("api.telegram.org"), + ); + expect(telegramCalls).toHaveLength(1); + }); + + it("reuses a ticket that is already in progress", async () => { + const envNoResend = { ...env, RESEND_API_KEY: undefined }; + const tool = handoffHumanTool(envNoResend, () => convId); + const first = await tool.execute!( + { reason: "humano", summary: "primera", category: "other" }, + {} as any, + ); + const ticketId = (first as { ticketId: string }).ticketId; + await env.DB.prepare("UPDATE tickets SET status = 'in_progress' WHERE id = ?").bind(ticketId).run(); + + const second = await tool.execute!( + { reason: "humano", summary: "sigue pidiendo una persona", category: "other" }, + {} as any, + ); + expect((second as { ticketId: string }).ticketId).toBe(ticketId); + const open = await tickets.listOpen(); + expect(open).toHaveLength(1); + expect(open[0].id).toBe(ticketId); + expect((await convs.getById(convId))?.open_ticket_id).toBe(ticketId); + }); + + it("opens a new ticket after the previous one was resolved", async () => { + const envNoResend = { ...env, RESEND_API_KEY: undefined }; + const tool = handoffHumanTool(envNoResend, () => convId); + const args = { + reason: "humano", + summary: "primera vez", + category: "product" as const, + }; + const first = await tool.execute!(args, {} as any); + const firstId = (first as { ticketId: string }).ticketId; + await tickets.resolve(firstId, "owner@test.com"); + + const second = await tool.execute!( + { ...args, summary: "volvió a pedir una persona" }, + {} as any, + ); + const secondId = (second as { ticketId: string }).ticketId; + expect(secondId).not.toBe(firstId); + + const open = await tickets.listOpen(); + expect(open).toHaveLength(1); + expect(open[0].id).toBe(secondId); + expect((await convs.getById(convId))?.open_ticket_id).toBe(secondId); + }); });