From 8c8bd942072cda41617997605cc888fe14219f70 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 8 Oct 2026 02:35:44 +0000 Subject: [PATCH] =?UTF-8?q?fix(handoff):=20no=20abrir=20un=20ticket=20nuev?= =?UTF-8?q?o=20si=20la=20conversaci=C3=B3n=20ya=20tiene=20uno=20abierto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El traspaso llamaba tickets.create() y setOpenTicket() en cada mensaje, así que el mismo pedido dejaba varios tickets open y un aviso al dueño por cada uno. Si open_ticket_id apunta a un ticket sin resolver, se le añade el contexto y no se vuelve a notificar. Co-authored-by: santmun --- src/db/tickets.ts | 9 ++++ src/tools/handoffHuman.ts | 23 +++++++-- test/tools/handoffHuman.test.ts | 85 ++++++++++++++++++++++++++++++++- 3 files changed, 113 insertions(+), 4 deletions(-) diff --git a/src/db/tickets.ts b/src/db/tickets.ts index 62ee2cc7..3f947973 100644 --- a/src/db/tickets.ts +++ b/src/db/tickets.ts @@ -42,6 +42,15 @@ export class TicketsRepo { ); } + /** Añade contexto a un ticket que sigue abierto (mismo hilo, sin crear otro). */ + async appendSummary(id: string, extra: string): Promise { + const ticket = await this.getById(id); + if (!ticket) return; + // Conserva el contexto más reciente si el hilo se alarga. + const summary = `${ticket.summary}\n${extra}`.slice(-4000); + await this.db.run("UPDATE tickets SET summary = ? WHERE id = ?", [summary, id]); + } + async resolve(id: string, resolvedBy: string): Promise { await this.db.run( "UPDATE tickets SET status = 'resolved', resolved_at = ?, resolved_by = ? WHERE id = ?", diff --git a/src/tools/handoffHuman.ts b/src/tools/handoffHuman.ts index 451334db..4b5e6cfd 100644 --- a/src/tools/handoffHuman.ts +++ b/src/tools/handoffHuman.ts @@ -20,14 +20,31 @@ export function handoffHumanTool(env: Env, getConversationId: () => string | nul const convId = getConversationId(); const db = new Db(env.DB); const tickets = new TicketsRepo(db); + const convs = convId ? new ConversationsRepo(db) : null; + const note = `[${reason}] ${summary}`; + + // Un cliente que insiste sobre el mismo traspaso no abre otro ticket: + // el anterior quedaría abierto, huérfano de la conversación, y el dueño + // recibiría un aviso por cada mensaje. + if (convId && convs) { + const conv = await convs.getById(convId); + const existingId = conv?.open_ticket_id; + if (existingId) { + const existing = await tickets.getById(existingId); + if (existing && existing.status !== "resolved") { + await tickets.appendSummary(existing.id, note); + return { ticketId: existing.id }; + } + } + } + const ticketId = await tickets.create({ conversationId: convId, category, - summary: `[${reason}] ${summary}`, + summary: note, transcript: "", // populated by agent if it has access; left blank otherwise }); - if (convId) { - const convs = new ConversationsRepo(db); + if (convId && convs) { await convs.setOpenTicket(convId, ticketId); } diff --git a/test/tools/handoffHuman.test.ts b/test/tools/handoffHuman.test.ts index 557e899f..e85d53ed 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"; @@ -28,6 +28,10 @@ beforeEach(async () => { }; }); +afterEach(() => { + vi.restoreAllMocks(); +}); + describe("handoffHumanTool", () => { it("creates a ticket row in D1 even without Resend key", async () => { const envNoResend = { ...env, RESEND_API_KEY: undefined }; @@ -45,4 +49,83 @@ describe("handoffHumanTool", () => { expect(list).toHaveLength(1); expect(list[0].summary).toContain("María"); }); + + it("reuses the open ticket on later handoffs and notifies the owner only once", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok")); + const envNotify = { + ...env, + RESEND_API_KEY: undefined, + TELEGRAM_BOT_TOKEN: "123:abc", + OWNER_TELEGRAM_CHAT_ID: "999", + }; + const tool = handoffHumanTool(envNotify, () => convId); + const first = await tool.execute!( + { reason: "humano", summary: "quiere que lo llamen", category: "product" }, + {} as any, + ); + const second = await tool.execute!( + { reason: "humano", summary: "pregunta otra vez cuándo lo llaman", category: "product" }, + {} as any, + ); + const third = await tool.execute!( + { reason: "humano", summary: "insiste con la misma llamada", category: "product" }, + {} 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); + expect(list[0].summary).toContain("quiere que lo llamen"); + expect(list[0].summary).toContain("pregunta otra vez cuándo lo llaman"); + expect(list[0].summary).toContain("insiste con la misma llamada"); + + const conv = await new ConversationsRepo(new Db(env.DB)).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("opens a fresh ticket after the previous one was resolved", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok")); + const envNotify = { + ...env, + RESEND_API_KEY: undefined, + TELEGRAM_BOT_TOKEN: "123:abc", + OWNER_TELEGRAM_CHAT_ID: "999", + }; + const tool = handoffHumanTool(envNotify, () => convId); + const first = await tool.execute!( + { reason: "humano", summary: "primera solicitud", category: "other" }, + {} as any, + ); + const firstId = (first as { ticketId: string }).ticketId; + await tickets.resolve(firstId, "dueno@negocio.com"); + + const second = await tool.execute!( + { reason: "humano", summary: "otra solicitud ya resuelta la anterior", category: "other" }, + {} 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(open[0].summary).toContain("otra solicitud"); + + const conv = await new ConversationsRepo(new Db(env.DB)).getById(convId); + expect(conv?.open_ticket_id).toBe(secondId); + + const telegramCalls = fetchSpy.mock.calls.filter((c) => + String(c[0]).includes("api.telegram.org"), + ); + expect(telegramCalls).toHaveLength(2); + }); });