diff --git a/packages/types/package.json b/packages/types/package.json index 1277d91..bdcb1bb 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@codespar/types", - "version": "0.10.11", + "version": "0.10.12", "description": "Shared session interface contract for codespar runtimes", "type": "module", "main": "./dist/index.js", diff --git a/packages/types/src/testing/demo-scenarios-consistency.test.ts b/packages/types/src/testing/demo-scenarios-consistency.test.ts index 8e2ad4e..32ba05e 100644 --- a/packages/types/src/testing/demo-scenarios-consistency.test.ts +++ b/packages/types/src/testing/demo-scenarios-consistency.test.ts @@ -19,6 +19,8 @@ import type { DemoScenario } from "./demo-scenario.js"; import { SERVICE_INVOICE_SCENARIO } from "./demo-scenarios/service-invoice.js"; import { INSTALLMENT_NEGOTIATION_SCENARIO } from "./demo-scenarios/installment-negotiation.js"; import { PAYMENT_REJECTION_SCENARIO } from "./demo-scenarios/payment-rejection.js"; +import { CUSTOMER_DATA_REJECTION_SCENARIO } from "./demo-scenarios/customer-data-rejection.js"; +import { MERCHANT_BLOCKED_SCENARIO } from "./demo-scenarios/merchant-blocked.js"; import { SHARED_META_TOOL_DEFINITIONS } from "../meta-tool-definitions.js"; import type { SharedMetaToolDefinition } from "../meta-tool-definitions.js"; @@ -28,6 +30,8 @@ const ALL_SCENARIOS: DemoScenario[] = [ SERVICE_INVOICE_SCENARIO, INSTALLMENT_NEGOTIATION_SCENARIO, PAYMENT_REJECTION_SCENARIO, + CUSTOMER_DATA_REJECTION_SCENARIO, + MERCHANT_BLOCKED_SCENARIO, ]; const META_TOOL_NAME = /^codespar_[a-z_]+$/; diff --git a/packages/types/src/testing/demo-scenarios/customer-data-rejection.ts b/packages/types/src/testing/demo-scenarios/customer-data-rejection.ts new file mode 100644 index 0000000..810849d --- /dev/null +++ b/packages/types/src/testing/demo-scenarios/customer-data-rejection.ts @@ -0,0 +1,145 @@ +import type { DemoScenario } from "../demo-scenario.js"; + +/* Payment-failure triage — customer-data category, at the meta-tool abstraction. + * + * The agent attempts a Pix payment; `codespar_pay` returns a *successful* result + * whose business outcome is a decline with `category: "customer_data"` and + * `error_code: "INVALID_CPF_CNPJ"`. The irreplaceable judgment is reading that + * category: a customer-data problem is recoverable by asking the customer for a + * correction, so the agent notifies the customer (`codespar_notify`), and on the + * corrected turn retries `codespar_pay` (now confirmed) and issues the NF-e + * (`codespar_invoice`). No human escalation — the customer can fix the input. + * + * The agent never picks a provider: `codespar_pay` owns provider routing + * internally. What the agent routes is the *remediation* (ask the customer vs. + * escalate to a human), which is exactly the category-reading judgment a fixed + * lookup table gets wrong when a new rejection code shows up in production. + * + * The first `codespar_pay` mock is a SUCCESSFUL dispatch whose payload is a + * decline — a rejected payment is a successful API call returning a declined + * result, so the meta-tool trace still reports `status: "success"`. The agent + * reads the declined output and drives the correction. + */ +export const CUSTOMER_DATA_REJECTION_SCENARIO: DemoScenario = { + name: "customer-data-rejection", + servers: ["asaas", "nuvem-fiscal", "z-api"], + mocks: { + // Stateful array: first call is declined for invalid CPF/CNPJ, the retry + // with the corrected document succeeds. + codespar_pay: [ + { + status: "rejected", + category: "customer_data", + error_code: "INVALID_CPF_CNPJ", + reason: "Documento (CPF/CNPJ) do pagador inválido ou não encontrado", + }, + { id: "pay_demo_triage_001", status: "confirmed", method: "pix" }, + ], + codespar_notify: { messageId: "msg_demo_triage_001", sent: true }, + codespar_invoice: { + id: "nfe_demo_triage_001", + status: "autorizada", + pdf_url: "https://example.test/nfe_demo_triage_001.pdf", + }, + }, + turns: [ + { + message: + "Quero pagar R$ 2.500 via Pix do meu pedido. Meu CPF é 111.111.111-11.", + expectMetaTools: ["codespar_pay", "codespar_notify"], + }, + { + message: "Foi mal, o CPF correto é 390.533.447-05.", + expectMetaTools: ["codespar_pay", "codespar_invoice"], + }, + ], + aimockFixtures: { + fixtures: [ + { + match: { userMessage: "R$ 2.500", turnIndex: 0, hasToolResult: false }, + response: { + toolCalls: [ + { + name: "codespar_pay", + arguments: { + amount: 250000, + currency: "BRL", + method: "pix", + description: "Pagamento do pedido via Pix", + consumer_id: "111.111.111-11", + }, + }, + ], + finishReason: "tool_calls", + }, + }, + { + match: { userMessage: "R$ 2.500", turnIndex: 1, hasToolResult: true }, + response: { + toolCalls: [ + { + name: "codespar_notify", + arguments: { + channel: "whatsapp", + to: "+5511987654321", + message: + "O CPF informado é inválido, então não consegui concluir o pagamento. Pode me enviar o CPF correto que eu refaço a cobrança?", + }, + }, + ], + finishReason: "tool_calls", + }, + }, + { + match: { userMessage: "R$ 2.500", turnIndex: 2, hasToolResult: true }, + response: { + content: + "O CPF informado não passou na validação. Me envia o CPF correto que eu refaço o pagamento na hora.", + finishReason: "stop", + }, + }, + { + match: { userMessage: "CPF correto", turnIndex: 0, hasToolResult: false }, + response: { + toolCalls: [ + { + name: "codespar_pay", + arguments: { + amount: 250000, + currency: "BRL", + method: "pix", + description: "Pagamento do pedido via Pix (CPF corrigido)", + consumer_id: "390.533.447-05", + }, + }, + ], + finishReason: "tool_calls", + }, + }, + { + match: { userMessage: "CPF correto", turnIndex: 1, hasToolResult: true }, + response: { + toolCalls: [ + { + name: "codespar_invoice", + arguments: { + type: "nfe", + recipient: { name: "Cliente Demo", document: "39053344705" }, + items: [{ description: "Pedido pago via Pix", amount: 2500.0 }], + }, + }, + ], + finishReason: "tool_calls", + }, + }, + { + match: { userMessage: "CPF correto", turnIndex: 2, hasToolResult: true }, + response: { + content: + "Prontinho! Pagamento aprovado com o CPF corrigido e NF-e emitida. Qualquer coisa é só chamar.", + finishReason: "stop", + }, + }, + ], + }, +}; diff --git a/packages/types/src/testing/demo-scenarios/merchant-blocked.ts b/packages/types/src/testing/demo-scenarios/merchant-blocked.ts new file mode 100644 index 0000000..db0582d --- /dev/null +++ b/packages/types/src/testing/demo-scenarios/merchant-blocked.ts @@ -0,0 +1,84 @@ +import type { DemoScenario } from "../demo-scenario.js"; + +/* Payment-failure triage — non-recoverable category, at the meta-tool abstraction. + * + * The agent attempts a Pix payment; `codespar_pay` returns a *successful* result + * whose business outcome is a decline with `category: "non_recoverable"` and + * `error_code: "MERCHANT_BLOCKED"`. The irreplaceable judgment is reading that + * category: a blocked merchant account is NOT something the customer can fix, so + * the only correct remediation is to notify a human (`codespar_notify` to an + * internal ops channel) and stop. The agent does NOT retry the payment, does NOT + * ask the customer for anything, and does NOT issue an invoice. + * + * This is the discriminating half of the triage: the same surface + * (`codespar_pay` returns a decline with a category) routes to a completely + * different remediation than the customer-data case. A fixed ask-the-customer + * flow gets this wrong — it would pester the customer about data that is fine + * while the real fix is a human unblocking the merchant account. + */ +export const MERCHANT_BLOCKED_SCENARIO: DemoScenario = { + name: "merchant-blocked", + servers: ["asaas", "z-api"], + mocks: { + // Single rejected result — non-recoverable, so there is no retry element. + codespar_pay: { + status: "rejected", + category: "non_recoverable", + error_code: "MERCHANT_BLOCKED", + reason: "Conta do lojista bloqueada pelo adquirente", + }, + codespar_notify: { messageId: "msg_demo_triage_ops_001", sent: true }, + }, + turns: [ + { + message: "Quero pagar R$ 800 via Pix do pedido #4471.", + expectMetaTools: ["codespar_pay", "codespar_notify"], + }, + ], + aimockFixtures: { + fixtures: [ + { + match: { userMessage: "pedido #4471", turnIndex: 0, hasToolResult: false }, + response: { + toolCalls: [ + { + name: "codespar_pay", + arguments: { + amount: 80000, + currency: "BRL", + method: "pix", + description: "Pagamento do pedido #4471 via Pix", + }, + }, + ], + finishReason: "tool_calls", + }, + }, + { + match: { userMessage: "pedido #4471", turnIndex: 1, hasToolResult: true }, + response: { + toolCalls: [ + { + name: "codespar_notify", + arguments: { + channel: "slack", + to: "#payments-ops", + message: + "Pagamento do pedido #4471 bloqueado: conta do lojista MERCHANT_BLOCKED no adquirente. Não é recuperável pelo cliente — escalando para o time resolver.", + }, + }, + ], + finishReason: "tool_calls", + }, + }, + { + match: { userMessage: "pedido #4471", turnIndex: 2, hasToolResult: true }, + response: { + content: + "Não consegui concluir esse pagamento: a conta do lojista está bloqueada no adquirente, o que não dá para resolver pelo seu lado. Já escalei para o time de pagamentos cuidar disso.", + finishReason: "stop", + }, + }, + ], + }, +}; diff --git a/packages/types/src/testing/index.ts b/packages/types/src/testing/index.ts index e973273..c4b7227 100644 --- a/packages/types/src/testing/index.ts +++ b/packages/types/src/testing/index.ts @@ -4,6 +4,10 @@ export * from "./demo-scenario.js"; export { SERVICE_INVOICE_SCENARIO } from "./demo-scenarios/service-invoice.js"; export { INSTALLMENT_NEGOTIATION_SCENARIO } from "./demo-scenarios/installment-negotiation.js"; export { PAYMENT_REJECTION_SCENARIO } from "./demo-scenarios/payment-rejection.js"; +export { CUSTOMER_DATA_REJECTION_SCENARIO } from "./demo-scenarios/customer-data-rejection.js"; +export { MERCHANT_BLOCKED_SCENARIO } from "./demo-scenarios/merchant-blocked.js"; +export { DEMO_SCENARIO_MANIFEST } from "./scenario-manifest.js"; +export type { DemoScenarioName } from "./scenario-manifest.js"; // Re-export the contract descriptors from the testing entrypoint too, so a // conformance-test author imports the suite and the descriptors it drives diff --git a/packages/types/src/testing/scenario-manifest.test.ts b/packages/types/src/testing/scenario-manifest.test.ts new file mode 100644 index 0000000..11f00bb --- /dev/null +++ b/packages/types/src/testing/scenario-manifest.test.ts @@ -0,0 +1,38 @@ +/** + * Lockstep guard: the demo-scenario manifest's `version` must equal the + * package's published version, and its `scenarios` must name real, shipped + * scenarios whose `name` matches. + * + * Why this matters: a consumer asserts its `@codespar/types` pin equals + * `DEMO_SCENARIO_MANIFEST.version`. If a publish bumps the package version but + * forgets to bump the manifest, the manifest would point at a version nobody + * pins — silently disarming the version-alignment check. This test fails the + * publish before that can happen. + * + * package.json is read at runtime (not statically imported) so it stays outside + * the package's `rootDir`/build graph. + */ + +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { DEMO_SCENARIO_MANIFEST } from "./scenario-manifest.js"; +import { CUSTOMER_DATA_REJECTION_SCENARIO } from "./demo-scenarios/customer-data-rejection.js"; +import { MERCHANT_BLOCKED_SCENARIO } from "./demo-scenarios/merchant-blocked.js"; + +const pkg = JSON.parse( + readFileSync(new URL("../../package.json", import.meta.url), "utf8"), +) as { version: string }; + +describe("demo scenario manifest", () => { + it("version is in lockstep with the package version", () => { + expect(DEMO_SCENARIO_MANIFEST.version).toBe(pkg.version); + }); + + it("names exactly the shipped triage scenarios", () => { + const shipped = [ + CUSTOMER_DATA_REJECTION_SCENARIO.name, + MERCHANT_BLOCKED_SCENARIO.name, + ].sort(); + expect([...DEMO_SCENARIO_MANIFEST.scenarios].sort()).toEqual(shipped); + }); +}); diff --git a/packages/types/src/testing/scenario-manifest.ts b/packages/types/src/testing/scenario-manifest.ts new file mode 100644 index 0000000..9ae3e80 --- /dev/null +++ b/packages/types/src/testing/scenario-manifest.ts @@ -0,0 +1,32 @@ +/* ── Demo scenario manifest ─────────────────────────────────────── + * + * The published contract for the demo scenarios this package ships: the + * canonical set of scenario `name`s plus the package version. A test that drives + * these scenarios asserts against it so an example and the published scenarios + * cannot silently drift apart: + * + * - Completeness: a consumer asserts the set of scenarios it actually drives + * equals `scenarios` exactly. Publish a new scenario and forget to cover it, + * and the assertion fails. + * + * - Version-alignment: a consumer asserts its `@codespar/types` dependency is + * an exact pin equal to `version` — a caret/tilde or a stale pin fails, so a + * consumer always tracks the exact published scenario set. + * + * `version` is kept in lockstep with the package version by + * `scenario-manifest.test.ts`, which fails CI if they drift — so a publish that + * forgets to bump the manifest cannot ship. + * ─────────────────────────────────────────────────────────────── */ + +/** The demo-scenario contract: the published package version plus the scenario + * names a consumer of these scenarios must drive completely. */ +export const DEMO_SCENARIO_MANIFEST = { + /** Must equal this package's published version (see the lockstep test). */ + version: "0.10.12", + /** Scenario `name`s a consumer must drive, exactly. */ + scenarios: ["customer-data-rejection", "merchant-blocked"], +} as const; + +/** A manifest scenario name. */ +export type DemoScenarioName = + (typeof DEMO_SCENARIO_MANIFEST)["scenarios"][number];