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
195 changes: 194 additions & 1 deletion src/agent/tools.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import test from "node:test";
import assert from "node:assert";
import { agentTools } from "./tools.js";
import { RunContext } from "@openai/agents";
import { z } from "zod";
import { agentTools, rgCreateNeed, rgPreregisterDonation, rgRegisterByPhone } from "./tools.js";
import type { AgentContext } from "./context.js";
import type { Account } from "../domain/account.js";

test("agentTools registra las tools de donación", () => {
const names = new Set(agentTools.map((t: any) => t.name));
Expand All @@ -14,7 +18,196 @@ test("agentTools registra las tools de donación", () => {
assert.ok(names.has("rg_record_inventory_entry"));
});

// Lookahead `(?=`/`(?!` o lookbehind `(?<=`/`(?<!`; un grupo con nombre `(?<name>` no cuenta.
const LOOKAROUND = /\(\?<?[=!]/;

/** Recorre el JSON schema y devuelve los valores de `pattern` que usan lookaround. */
function patternsWithLookaround(schema: unknown): string[] {
if (Array.isArray(schema)) return schema.flatMap(patternsWithLookaround);
if (!schema || typeof schema !== "object") return [];
return Object.entries(schema).flatMap(([key, value]) =>
key === "pattern" && typeof value === "string"
? LOOKAROUND.test(value)
? [value]
: []
: patternsWithLookaround(value),
);
}

test("patternsWithLookaround solo mira los valores de `pattern`", () => {
// Lo que genera zod con `.email()` debe detectarse.
assert.ok(patternsWithLookaround(z.toJSONSchema(z.object({ email: z.string().email() }))).length > 0);

const schema = {
type: "object",
description: "Texto libre con (?=esto) que no es un pattern",
properties: {
year: { anyOf: [{ type: "string", pattern: "^(?<year>\\d{4})$" }, { type: "null" }] },
tags: { type: "array", items: { type: "string", pattern: "(?<!x)y" } },
},
};
assert.deepStrictEqual(patternsWithLookaround(schema), ["(?<!x)y"]);
});

test("ningún schema de tool usa lookaround en `pattern` (strict de OpenAI lo rechaza en silencio)", () => {
// Un `pattern` con `(?!…)`/`(?=…)` (lo que genera zod con `.email()`) hace que la
// Responses API devuelva `incomplete: max_output_tokens` sin output para TODO el
// set de tools: el agente agota maxTurns y el bot deja de contestar a todos.
for (const t of agentTools as any[]) {
assert.deepStrictEqual(patternsWithLookaround(t.parameters), [], `${t.name} tiene un pattern con lookaround`);
}
});

test("rg_record_inventory_entry se documenta como acción de staff, no de donación", () => {
const inv = agentTools.find((t: any) => t.name === "rg_record_inventory_entry") as any;
assert.match(inv.description, /rg_preregister_donation|donar|donaci/i);
});

const account: Account = {
id: "acc-1",
channel: "telegram",
emergencySlug: "sismo-2026",
apiToken: "rg_live_test",
telegramBotToken: "bot-1",
};

const EMERGENCY_ID = "11111111-1111-4111-8111-111111111111";
const RESOURCE_ID = "22222222-2222-4222-8222-222222222222";

/** Contexto de agente con un apiClient falso que registra las peticiones. */
function fakeContext() {
const requests: Array<{ method: string; path: string; body: any }> = [];
const context = {
channel: "telegram",
chatId: "chat-1",
account,
user: {},
authenticated: true,
verifiedPhone: "+34600000000",
apiClient: {
request: async (method: string, path: string, body?: unknown) => {
requests.push({ method, path, body });
return { id: "creado" };
},
},
} as unknown as AgentContext;
return { runContext: new RunContext(context), requests };
}

/** Sustituye fetch (lo usa TrustedAuthClient) y cuenta las llamadas. */
async function withMockedFetch(response: () => Response, run: (calls: { count: number }) => Promise<void>) {
const original = globalThis.fetch;
const calls = { count: 0 };
globalThis.fetch = (async () => {
calls.count++;
return response();
}) as unknown as typeof fetch;
try {
await run(calls);
} finally {
globalThis.fetch = original;
}
}

const donationItems = [{ name: "Agua", quantity: 10, category: "water" }];

test("validación de email en las tools", async (t) => {
await t.test("rg_register_by_phone pide otro email si el formato no es válido, sin llamar a la API", async () => {
const { runContext } = fakeContext();
await withMockedFetch(
() => new Response("{}", { status: 201 }),
async (calls) => {
const result = await rgRegisterByPhone.invoke(
runContext,
JSON.stringify({ name: "Ana Pérez", email: "ana@correo", acceptedTerms: true }),
);
assert.match(String(result), /El email 'ana@correo' no parece válido/);
assert.match(String(result), /escriba de nuevo/);
assert.strictEqual(calls.count, 0, "no debería llamar a register-by-phone");
},
);
});

await t.test("rg_register_by_phone pide revisar los datos si la API responde 400", async () => {
const { runContext } = fakeContext();
await withMockedFetch(
() => new Response(JSON.stringify({ statusCode: 400, message: ["email must be an email"] }), { status: 400 }),
async () => {
const result = await rgRegisterByPhone.invoke(
runContext,
JSON.stringify({ name: "Ana Pérez", email: "ana@correo.com", acceptedTerms: true }),
);
assert.match(String(result), /no parece válido/);
assert.doesNotMatch(String(result), /An error occurred/);
},
);
});

await t.test("rg_preregister_donation pide revisar u omitir un donorEmail no válido, sin llamar a la API", async () => {
const { runContext, requests } = fakeContext();
const result = await rgPreregisterDonation.invoke(
runContext,
JSON.stringify({
emergencyId: EMERGENCY_ID,
targetResourceId: RESOURCE_ID,
donorName: "Ana Pérez",
donorEmail: "ana(at)correo.com",
items: donationItems,
}),
);
assert.match(String(result), /El email 'ana\(at\)correo\.com' no parece válido/);
assert.match(String(result), /omit/);
assert.strictEqual(requests.length, 0);
});

await t.test("rg_preregister_donation envía el donorEmail válido recortado", async () => {
const { runContext, requests } = fakeContext();
await rgPreregisterDonation.invoke(
runContext,
JSON.stringify({
emergencyId: EMERGENCY_ID,
targetResourceId: RESOURCE_ID,
donorName: "Ana Pérez",
donorEmail: " ana@correo.com ",
items: donationItems,
}),
);
assert.strictEqual(requests.length, 1);
assert.strictEqual(requests[0]!.path, `/emergencies/${EMERGENCY_ID}/donation-intakes`);
assert.strictEqual(requests[0]!.body.donorEmail, "ana@correo.com");
});

await t.test("rg_create_need rechaza un author.email no válido, sin llamar a la API", async () => {
const { runContext, requests } = fakeContext();
const result = await rgCreateNeed.invoke(
runContext,
JSON.stringify({
emergencyId: EMERGENCY_ID,
title: "Agua potable",
location: { address: "Plaza Mayor", latitude: 10.5, longitude: -66.9 },
priority: "high",
items: donationItems,
author: { name: "Ana", email: "ana@" },
}),
);
assert.match(String(result), /El email 'ana@' no parece válido/);
assert.strictEqual(requests.length, 0);
});

await t.test("rg_create_need envía el author.email válido recortado y conserva el resto del author", async () => {
const { runContext, requests } = fakeContext();
await rgCreateNeed.invoke(
runContext,
JSON.stringify({
emergencyId: EMERGENCY_ID,
title: "Agua potable",
location: { address: "Plaza Mayor", latitude: 10.5, longitude: -66.9 },
priority: "high",
items: donationItems,
author: { name: "Ana", email: " ana@correo.com " },
}),
);
assert.strictEqual(requests.length, 1);
assert.deepStrictEqual(requests[0]!.body.author, { name: "Ana", email: "ana@correo.com" });
});
});
87 changes: 76 additions & 11 deletions src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import {
TrustedAuthClient,
PhoneNotFoundError,
EmailAlreadyExistsError,
InvalidRegistrationDataError,
} from "../infrastructure/responsegrid/trusted-auth-client.js";
import { ApiClient } from "../infrastructure/responsegrid/api-client.js";
import { Email } from "../domain/email.js";
import { toToolJson } from "./tool-result.js";

const trustedAuthClient = new TrustedAuthClient();
Expand Down Expand Up @@ -87,9 +89,17 @@ const supplyLineSchema = z.object({
.describe("Fecha de caducidad o frescura en formato YYYY-MM-DD, si aplica."),
});

// Fuente única para los campos email de las tools: solo `z.string()`. Con `.email()` zod emite un
// `pattern` con lookahead que el modo strict de OpenAI rechaza (y deja mudo al bot), y en los campos
// opcionales el SDK además elimina format/pattern sin avisar. Por eso el formato se valida en
// `execute` con el value object Email.
function emailField(description: string) {
return z.string().describe(description);
}

const authorSchema = z.object({
name: z.string().optional(),
email: z.string().email().optional(),
email: emailField("Email de contacto del autor, si lo da.").optional(),
phone: z.string().optional(),
note: z.string().optional(),
verified: z.boolean().optional(),
Expand Down Expand Up @@ -153,6 +163,35 @@ function requireAuth(context: AgentContext): void {
}
}

function invalidEmailMessage(raw: string, followUp: string): string {
return `El email '${raw}' no parece válido. ${followUp}`;
}

/**
* Normaliza con el value object Email un email opcional que llega del modelo: `{ value }` con el
* email recortado (vacío si no viene) o `{ error }` con el mensaje para el agente si no es válido.
*/
function normalizeOptionalEmail(raw: string | undefined): { value?: string; error?: string } {
if (!raw) {
return {};
}
const email = Email.tryCreate(raw);
return email
? { value: email.value }
: { error: invalidEmailMessage(raw, "Pide al usuario que lo revise o que lo omita si prefiere no darlo.") };
}

/** Valida y normaliza `author.email`: devuelve el mensaje para el agente o el input con el email recortado. */
function withNormalizedAuthorEmail<T extends { author?: { email?: string | undefined } | undefined }>(
input: T,
): T | string {
const { value, error } = normalizeOptionalEmail(input.author?.email);
if (error) {
return error;
}
return input.author ? { ...input, author: { ...input.author, email: value } } : input;
}

export const rgGetApiIdentity = tool({
name: "rg_get_api_identity",
description:
Expand Down Expand Up @@ -351,8 +390,12 @@ export const rgRegisterResource = tool({
execute: async (input, runContext?: RunContext<AgentContext>) => {
const context = getContext(runContext);
requireAuth(context);
const emergencyId = await resolveEmergencyId(context, input);
const { emergencyId: _eid, emergencySlug: _slug, ...payload } = input;
const normalized = withNormalizedAuthorEmail(input);
if (typeof normalized === "string") {
return normalized;
}
const emergencyId = await resolveEmergencyId(context, normalized);
const { emergencyId: _eid, emergencySlug: _slug, ...payload } = normalized;
const result = await context.apiClient.request(
"POST",
`/emergencies/${emergencyId}/resources`,
Expand Down Expand Up @@ -432,17 +475,21 @@ export const rgPreregisterDonation = tool({
.describe("ID del punto de acopio destino donde la persona entregará la donación."),
donorName: z.string().min(2).describe("Nombre de quien dona."),
donorPhone: z.string().optional().describe("Teléfono de contacto del donante, si lo da."),
donorEmail: z.string().email().optional().describe("Email del donante, si lo da."),
donorEmail: emailField("Email del donante, si lo da.").optional(),
items: z.array(supplyLineSchema).min(1),
}),
execute: async (input, runContext?: RunContext<AgentContext>) => {
const context = getContext(runContext);
const donorEmail = normalizeOptionalEmail(input.donorEmail);
if (donorEmail.error) {
return donorEmail.error;
}
const emergencyId = await resolveEmergencyId(context, input);
const { emergencyId: _eid, emergencySlug: _slug, ...payload } = input;
const result = await context.apiClient.request(
"POST",
`/emergencies/${emergencyId}/donation-intakes`,
payload,
{ ...payload, donorEmail: donorEmail.value },
);
return asPrettyJson(result);
},
Expand All @@ -463,8 +510,12 @@ export const rgSubmitOffer = tool({
execute: async (input, runContext?: RunContext<AgentContext>) => {
const context = getContext(runContext);
requireAuth(context);
const emergencyId = await resolveEmergencyId(context, input);
const { emergencyId: _eid, emergencySlug: _slug, ...payload } = input;
const normalized = withNormalizedAuthorEmail(input);
if (typeof normalized === "string") {
return normalized;
}
const emergencyId = await resolveEmergencyId(context, normalized);
const { emergencyId: _eid, emergencySlug: _slug, ...payload } = normalized;
const result = await context.apiClient.request(
"POST",
`/emergencies/${emergencyId}/offers`,
Expand Down Expand Up @@ -613,8 +664,12 @@ export const rgCreateNeed = tool({
execute: async (input, runContext?: RunContext<AgentContext>) => {
const context = getContext(runContext);
requireAuth(context);
const emergencyId = await resolveEmergencyId(context, input);
const { emergencyId: _eid, emergencySlug: _slug, ...payload } = input;
const normalized = withNormalizedAuthorEmail(input);
if (typeof normalized === "string") {
return normalized;
}
const emergencyId = await resolveEmergencyId(context, normalized);
const { emergencyId: _eid, emergencySlug: _slug, ...payload } = normalized;
const result = await context.apiClient.request(
"POST",
`/emergencies/${emergencyId}/needs`,
Expand Down Expand Up @@ -709,7 +764,9 @@ export const rgRegisterByPhone = tool({
"Da de alta una cuenta nueva de ResponseGrid a partir del teléfono ya verificado del usuario, cuando rg_request_user_login ha respondido que no existe cuenta. Requiere que el usuario haya confirmado explícitamente que acepta los términos y la política de privacidad antes de llamarla.",
parameters: z.object({
name: z.string().min(2).describe("Nombre completo del usuario."),
email: z.string().email().describe("Email del usuario."),
// Sin `.email()`: el modo strict de OpenAI rechaza los `pattern` con lookahead que genera zod
// (y con ellos TODO el set de tools). El formato se valida en `execute` con el value object Email.
email: emailField("Email del usuario."),
acceptedTerms: z
.boolean()
.describe("true solo si el usuario ha confirmado explícitamente que acepta términos y privacidad."),
Expand All @@ -725,18 +782,26 @@ export const rgRegisterByPhone = tool({
return "No puedo crear la cuenta sin que el usuario confirme explícitamente que acepta los términos y la política de privacidad. Pídeselo de nuevo antes de reintentar.";
}

const email = Email.tryCreate(input.email);
if (!email) {
return invalidEmailMessage(input.email, "Pide al usuario que lo revise y lo escriba de nuevo.");
}

try {
const result = await trustedAuthClient.registerByPhone(context.account, {
phone: context.verifiedPhone,
name: input.name,
email: input.email,
email: email.value,
});
applyUserLogin(context, result.accessToken);
return `Cuenta creada y autenticada con éxito como ${result.user.name} (${result.user.email}).`;
} catch (error) {
if (error instanceof EmailAlreadyExistsError) {
return "Ya existe una cuenta de ResponseGrid con ese email. Pide al usuario un email distinto.";
}
if (error instanceof InvalidRegistrationDataError) {
return `ResponseGrid ha rechazado los datos de registro: el email '${email.value}' no parece válido o algún otro dato es incorrecto. Pide al usuario que revise su nombre y su email y los escriba de nuevo.`;
}
throw error;
}
},
Expand Down
Loading