Skip to content

Commit 14c6a64

Browse files
committed
fix(ai): invocação sem agente dono some da auditoria de custo (#160)
Reportado por @jmpo medindo a própria VPS: `ai_invocations` com ZERO linhas numa instalação com 176 mensagens em 24h, e o log repetindo `invalid input syntax for type uuid: ""`. Como o insert é fire-and-forget, o custo era pago ao provider e não aparecia em tela nenhuma — `ai_invocations` é a fonte de `/api/v1/ai/usage`, `/api/v1/admin/usage`, `UsageTable` e `TenantOverview`. O classificador de sentimento roda MESMO sem agente ativo (lê o agente só para o threshold e cai no default, o que está certo), mas auditava com `agent_id: agent?.id ?? ""` numa coluna `uuid NOT NULL`. E "sem agente ativo" é o estado NORMAL de quem ainda não publicou o agente. Das duas saídas que ele propôs, esta é a que descreve a realidade — existe invocação de IA que não pertence a agente nenhum. A outra (não logar sem agente) trocaria bug silencioso por lacuna silenciosa. O CONSERTO NÃO É NO CALL SITE, e medi por quê: com `agent_id: string | null`, um chamador escrevendo `?? ""` passa no typecheck E no teste que só olha `null` — os dois gates cegos ao mesmo tempo. Quem escreve na coluna uuid é `logInvocation`, então é ela que normaliza vazio/branco para null. Achado de lado, na mesma função: o tipo de `invocation_kind` oferecia quatro valores que o CHECK recusa (`sentiment_check`, `embed_chunk`, `embed_query`, `intent_classify`) e omitia dois que aceita. Nenhum em uso, então sem sintoma — armadilha carregada com o mesmo modo de falha silencioso. Alinhado ao banco, e o par entrou no invariante de vocabulário, que é o único gate que enxerga o Postgres. Sabotado: tirar a normalização deixa 2 casos vermelhos. gov:verify verde (273 arquivos, 2611 testes); test:db verde (install + update + 463 invariantes, incluindo o par novo). Closes #160
1 parent 98c3a02 commit 14c6a64

7 files changed

Lines changed: 234 additions & 10 deletions

File tree

lib/ai/log-invocation.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,40 @@
88
import { logger } from "@/lib/logger";
99
import { createAdminClient } from "@/lib/supabase/admin";
1010

11+
/** Espelha o CHECK de `ai_invocations.invocation_kind` (ver o invariante). */
12+
export type InvocationKind =
13+
| "bot_respond"
14+
| "sentiment_classify"
15+
| "triage_classify"
16+
| "embedding_generate";
17+
1118
export interface LogInvocationInput {
1219
organization_id: string;
13-
agent_id: string;
20+
/**
21+
* NULL quando a invocação não pertence a agente nenhum — é o caso do
22+
* classificador de sentimento numa org que ainda não publicou agente (issue
23+
* #160). Antes isto era `string` e o chamador mandava `""` para preencher,
24+
* que o Postgres recusa como uuid. Como o insert é fire-and-forget, o custo
25+
* era pago e sumia: a tabela ficava vazia e as telas de consumo mostravam
26+
* zero. Tipo que aceita `null` é o que descreve a realidade.
27+
*/
28+
agent_id: string | null;
1429
conversation_id: string | null;
1530
message_id: string | null;
16-
invocation_kind:
17-
| "bot_respond"
18-
| "sentiment_check"
19-
| "sentiment_classify"
20-
| "embed_chunk"
21-
| "embed_query"
22-
| "intent_classify";
31+
/**
32+
* O vocabulário é o do CHECK de `ai_invocations.invocation_kind` — nem um
33+
* valor a mais.
34+
*
35+
* Achado junto com a #160: este tipo listava `sentiment_check`,
36+
* `embed_chunk`, `embed_query` e `intent_classify`, que o banco NÃO aceita, e
37+
* omitia `triage_classify` e `embedding_generate`, que aceita. Nenhum dos
38+
* quatro estava em uso, então não havia sintoma — era armadilha carregada:
39+
* quem escolhesse um deles pelo autocomplete teria um `23514` num INSERT
40+
* fire-and-forget, ou seja, silêncio. O par entra em
41+
* `tests/invariants/vocabulario-banco-x-typescript.test.ts`, que é o único
42+
* gate que enxerga o banco.
43+
*/
44+
invocation_kind: InvocationKind;
2345
model: string;
2446
prompt_tokens: number;
2547
completion_tokens: number;
@@ -37,7 +59,15 @@ export function logInvocation(row: LogInvocationInput): void {
3759
const admin = createAdminClient();
3860
const { error } = await admin.from("ai_invocations").insert({
3961
organization_id: row.organization_id,
40-
agent_id: row.agent_id,
62+
// NORMALIZA AQUI, e não no chamador (issue #160). O tipo já diz
63+
// `string | null`, mas `string` aceita `""` — e foi exatamente `?? ""`
64+
// que fez esta tabela ficar vazia numa VPS com tráfego real, porque o
65+
// Postgres recusa string vazia como uuid e o insert é fire-and-forget.
66+
// Consertar só o chamador que apareceu deixaria a armadilha armada
67+
// para o próximo: quem escreve na coluna uuid é esta função, então é
68+
// ela que fecha a porta. `?? null` continua sendo o certo lá; isto é a
69+
// rede embaixo.
70+
agent_id: row.agent_id === null || row.agent_id.trim() === "" ? null : row.agent_id,
4171
conversation_id: row.conversation_id,
4272
message_id: row.message_id,
4373
invocation_kind: row.invocation_kind,

supabase/baseline.sql

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9123,4 +9123,24 @@ grant execute on function public.fn_user_role_in_org(uuid) to authenticated, ser
91239123
grant execute on function public.fn_user_role_in(uuid) to authenticated, service_role;
91249124
grant execute on function public.fn_role_at_least(uuid, text) to authenticated, service_role;
91259125

9126+
-- ---- ai_invocations.agent_id aceita NULL (migration 0114) ----
9127+
-- Issue #160 (@jmpo, medindo a própria VPS): o classificador de sentimento roda
9128+
-- mesmo sem agente ativo — lê o agente só para o threshold e cai no default —
9129+
-- mas auditava com `agent_id: agent?.id ?? ""` numa coluna `uuid NOT NULL`. O
9130+
-- insert é fire-and-forget, então o erro só aparecia como `warn` no log do
9131+
-- contêiner: `ai_invocations` ficava VAZIA numa instalação com tráfego real, e
9132+
-- as telas de consumo e custo de IA (que leem dela) mostravam zero enquanto o
9133+
-- provider era pago. "Sem agente ativo" é o estado normal de quem ainda não
9134+
-- publicou o agente.
9135+
-- Idempotente: `drop not null` em coluna que já aceita null é no-op.
9136+
9137+
alter table public.ai_invocations
9138+
alter column agent_id drop not null;
9139+
9140+
comment on column public.ai_invocations.agent_id is
9141+
'Agente que originou a invocação. NULL = invocação de IA sem agente dono '
9142+
'(ex.: classificador de sentimento numa org sem agente publicado). O custo '
9143+
'existe e precisa aparecer nas telas de consumo — ver issue #160.';
9144+
9145+
91269146
notify pgrst, 'reload schema';
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
-- 0114: ai_invocations.agent_id passa a aceitar NULL
2+
--
3+
-- Issue #160, reportada por @jmpo medindo a própria VPS. `ai_invocations` é a
4+
-- fonte das telas de consumo e custo de IA (`/api/v1/ai/usage`,
5+
-- `/api/v1/admin/usage`, `lib/ai/usage/aggregate.ts`, `UsageTable`,
6+
-- `TenantOverview`). Ele achou a tabela com **zero linhas** numa instalação com
7+
-- 176 mensagens em 24h, e o log repetindo:
8+
--
9+
-- [ai-invocations] insert failed
10+
-- invalid input syntax for type uuid: "" (invocation_kind: sentiment_classify)
11+
--
12+
-- O classificador de sentimento roda MESMO sem agente ativo — ele lê o agente só
13+
-- para descobrir o threshold e cai no default quando não acha, o que está certo.
14+
-- Mas na hora de auditar mandava `agent_id: agent?.id ?? ""`, e a coluna era
15+
-- `uuid NOT NULL`. Como o insert é fire-and-forget, nada quebrava na cara de
16+
-- ninguém: o custo era pago ao provider e não aparecia em tela nenhuma.
17+
--
18+
-- "Sem agente ativo" não é estado exótico: é o estado NORMAL de quem ainda não
19+
-- publicou o agente. O onboarding cria e a publicação é um passo à parte —
20+
-- entre os dois, a instalação tem mensagem chegando e nenhum agente ativo.
21+
--
22+
-- DAS DUAS SAÍDAS, esta é a que descreve a realidade: existe invocação de IA
23+
-- que não pertence a agente nenhum. A alternativa (não logar quando não há
24+
-- agente) trocaria um bug silencioso por uma LACUNA silenciosa — o dono seguiria
25+
-- pagando por algo invisível, que é o dano que a issue nomeia.
26+
--
27+
-- As telas não quebram: `/api/v1/ai/usage` só filtra por `agent_id` quando o
28+
-- parâmetro vem, e a agregação sem filtro soma tudo. Linha sem agente passa a
29+
-- aparecer no total, que é exatamente o que faltava.
30+
--
31+
-- Idempotente: `drop not null` em coluna que já aceita null é no-op.
32+
33+
alter table public.ai_invocations
34+
alter column agent_id drop not null;
35+
36+
comment on column public.ai_invocations.agent_id is
37+
'Agente que originou a invocação. NULL = invocação de IA sem agente dono '
38+
'(ex.: classificador de sentimento numa org sem agente publicado). O custo '
39+
'existe e precisa aparecer nas telas de consumo — ver issue #160.';

supabase/migrations/MANIFEST.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ aplica.
108108
| `20260724120000` | `0068_skills_marketplace` | Épico Harness (F2): `skill_versions` ganha `manifest` (lista de arquivos {path,size,sha256,kind}) + `forked_from_version_id`; tabela `skill_activations` (telemetria hard/probe por turno); bucket `skill-assets` (privado, 5MB); policy SELECT de catálogo (`organization_id is null`) em skill_versions/pointers p/ o marketplace ser legível user-scoped. RLS `tenant_isolation` em skill_activations. NNNN=0068 (maior real era 0067; verificado em todas as branches). `database.types.ts` regenerado. |
109109
| `20260724130000` | `0069_seed_platform_skills` | Épico Harness (F2): seed de 2 skills de plataforma (`organization_id null`) no catálogo do marketplace — `objecao-preco` (contornar objeção de preço, vendas/genérico) e `agendamento` (marcar/remarcar horário, clínicas/serviços). Cada uma com body markdown if-then (≤200 linhas) + `matcher` `{any_keywords, probe_keywords}` validado por `skillMatcherSchema` (app-only, sem CHECK no banco). Idempotente via guard `where not exists (select 1 from skill_pointers where organization_id is null and name=...)` — reaplicar não duplica versão nem ponteiro (provado: 2 pointers/1 versão cada, antes e depois do re-apply). NNNN=0069 (maior real era 0068; sem colisão nas branches locais). Sem mudança de contrato de schema — `database.types.ts` não regenerado (seed de dados, não DDL). |
110110
| `20260725150000` | `0113_ai_pricing_backfill` | **Renumerada de 0068 para 0113 (2026-08-06):** nasceu com um NNNN ja usado por `0068_skills_marketplace` (`20260724120000`), e numero repetido quebra a IDENTIDADE da migration — duas linhas diferentes respondendo pelo mesmo nome. O timestamp NAO mudou, entao a `version` que o Supabase registra e a mesma e nenhum clone re-aplica; so o rotulo de rastreabilidade mudou. As strings `notes` gravadas no banco (`backfill 0068 ...`) foram DELIBERADAMENTE mantidas: sao dado historico do que rodou, nao rastreabilidade de arquivo. **Corrige custo 0 e budget sem teto em toda instalação nova.** Os seeds de `ai_pricing` existem só na 0010, mas a cadeia fresh de migrations não sobe (as 10 primeiras são stubs `SELECT 1;`) e quem instala aplica o `baseline.sql`, que semeia `ai_models` mas NÃO `ai_pricing`. Com a tabela vazia, `computeCost()` (lib/ai/cost.ts) faz lookup exato por `model` e devolve 0 sem log e sem throw → `ai_invocations.cost_cents` grava 0 e `ai_budgets` nunca acumula gasto, então o teto por organização jamais dispara (o tenant configura limite, a UI mostra, e ele não existe). FIX genérico e auto-curativo: deriva `ai_pricing` de `ai_models`, que já tem os preços do catálogo — sem hardcode, cobre modelo futuro; mais a linha do embedding `openai/text-embedding-3-small` (20¢/M, valor da 0010), que não vive em `ai_models`. Guardado por NOT EXISTS: banco que já rodava com a 0010 não muda. Prova: `computeCost('claude-sonnet-4-6', 1M, 1M)` era 0, passa a 1800¢. Sem mudança de schema → `database.types.ts` intocado. |
111+
| `20260806120000` | `0114_ai_invocations_agent_id_nullable` | Issue #160 (@jmpo): `ai_invocations.agent_id` deixa de ser NOT NULL. O classificador de sentimento roda mesmo sem agente ativo (lê o agente só para o threshold e cai no default) e auditava com `agent?.id ?? ""` — string vazia numa coluna uuid. Insert fire-and-forget, então o erro só aparecia como `warn` no log: a tabela ficava VAZIA numa instalação com 176 mensagens/24h, e as telas de consumo e custo de IA que leem dela mostravam zero enquanto o provider era pago. Das duas saídas propostas na issue, esta é a que descreve a realidade — existe invocação de IA sem agente dono; a outra trocaria bug silencioso por lacuna silenciosa. NNNN=0114 (0110–0113 ocupados). Idempotente. |
111112
| `20260725000000` | `0070_crm_lead_owner_kind` | CRM Vivo · Wave 1 (CORE 1 — a IA é dona do negócio): `crm_leads` ganha `owner_kind ('user'\|'ai')` + `owner_agent_id uuid references ai_agents(id) on delete set null`, no padrão da 0032 (`conversations.assignee_kind`) — backfill ANTES da constraint e CHECK `crm_leads_owner_kind_coherence` em forma de implicação (drop+add, re-aplicável). FK aponta para `ai_agents` (identidade), NUNCA `ai_agent_versions`: o tooltip do card resolve "Nome · vN" por join na versão publicada no momento da exibição — congelar a versão no lead faria o card mentir depois de um republish. Índice parcial `idx_crm_leads_owner_agent (organization_id, owner_agent_id) where owner_agent_id is not null`. `fn_emit_event_on_lead_change()` re-assentada (`create or replace`, corpo da 0043 + ramo do agente): `lead.assigned` passa a disparar também quando `owner_agent_id` muda, com `from_agent_id`/`to_agent_id`/`owner_kind` no payload — sem isso a coluna nova seria ilha (nenhum consumidor lê o payload hoje, então ele só cresce). NNNN=0070 (0068/0069 tomados por `feat/harness-*`, verificado com `git ls-tree` em TODAS as branches). |
112113
| `20260725010000` | `0071_crm_lead_activities_barramento` | CRM Vivo · Wave 3 bloco 1 (CORE 2 — **só schema**, emissores e UI vêm depois): `crm_lead_activities` vira o barramento único da vida do lead — `actor_kind ('user'\|'ai'\|'system'\|'rule'\|'contact')`, `actor_agent_id` (FK → `ai_agents`), `reason text`, `evidence jsonb`. O quinto valor é `contact` (a pessoa atendida) e **não** `lead`: deste lado da casa lead é o NEGÓCIO, e `agent` já é papel humano de RBAC. **Backfill a partir do JSONB antes da constraint**: `actor_kind`/`reason` já eram gravados dentro de `metadata` (`lib/ai/handoff/orchestrator.ts`) e teriam sido apagados por um backfill cego de `'system'`; `evidence` sobe de `metadata.run_ids`/`trace_ids`; linha marcada `'ai'` sem lastro nenhum degrada para `'system'` (preserva o registro, recusa a autoria sem prova) para o `update.sh` de clones não quebrar. Constraint `crm_lead_activities_ai_needs_evidence` com `jsonb_array_length(...) > 0` — **não** `evidence ? 'run_ids'`, que passa com array vazio. Fronteira DIRC fixada em `comment on column`: `source_module`/`source_id` = o que ORIGINOU (um ponteiro), `evidence` = o que SUSTENTA (N referências), e evidence nunca repete o source_id. De carona: `crm_leads.stage_changed_at` + trigger `trg_stamp_stage_changed_at` (sem HTTP), para o card medir tempo NO ESTÁGIO em vez de tempo sem resposta. `crm_lead_activities` entra na publicação `supabase_realtime` (o dossiê assina filtrado por `lead_id`; o board não assina esta tabela — §3.5). NNNN=0071 (0070 é meu, 0068/0069 em `feat/harness-*`; verificado com `git ls-tree` em todas as branches). |
113114
| `20260725020000` | `0072_activity_evidence_llm_call_ids` | CRM Vivo · Wave 3 — corrige o **destino** do lastro. A 0071 definiu `evidence` como `{run_ids, trace_ids}` "no formato de `flywheel_distiller_proposals.evidence`", mas o único escritor real (o turno do agente) guardava em `run_ids` um id de **`llm_calls`** — outra tabela, não `ai_agent_runs`. O nome mentia sobre o ponteiro: quem seguisse a trilha faria join contra `ai_agent_runs` e receberia VAZIO, sem erro e sem aviso. A promessa do CORE 2 é "toda afirmação da IA tem lastro", e lastro que aponta para a tabela errada cumpre a constraint sem cumprir a promessa — agravado por `evidence` ser trilha PERMANENTE (o ensaio de LGPD provou que ela sobrevive à anonimização, por só guardar ids). A constraint `crm_lead_activities_ai_needs_evidence` passa a aceitar também `llm_call_ids`, e o `comment on column` amarra cada chave à SUA tabela (`run_ids`→`ai_agent_runs`, `trace_ids`→trace do turno, `llm_call_ids`→`llm_calls`). **Só AFROUXA** — acrescenta uma terceira forma de lastro, então nenhuma linha existente passa a violar e o `update.sh` de clone não quebra; é o oposto do risco que fez recusar uma check constraint em `type` nesta mesma wave. Feito AGORA porque havia 1 escritor e 0 linhas reais (as 2 existentes são de semente): depois seria migration MAIS correção de dados. |

tests/invariants/vocabulario-banco-x-typescript.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,22 @@ const PARES: Array<{
8484
arquivo: "lib/types/leads.ts",
8585
simbolo: "LeadStatus",
8686
},
87+
{
88+
tabela: "ai_invocations",
89+
coluna: "invocation_kind",
90+
// lib/ai/log-invocation.ts → InvocationKind.
91+
//
92+
// Par nascido de divergência REAL, achada junto com a issue #160: o tipo
93+
// oferecia quatro valores que o CHECK recusa (`sentiment_check`,
94+
// `embed_chunk`, `embed_query`, `intent_classify`) e omitia dois que ele
95+
// aceita (`triage_classify`, `embedding_generate`). Nenhum estava em uso,
96+
// então não havia sintoma — o defeito era uma armadilha carregada: o insert
97+
// é fire-and-forget, então quem escolhesse um deles pelo autocomplete
98+
// colheria um `23514` que nunca chega à tela de ninguém. É o mesmo modo de
99+
// falha que deixou esta tabela VAZIA numa VPS com tráfego real.
100+
arquivo: "lib/ai/log-invocation.ts",
101+
simbolo: "InvocationKind",
102+
},
87103
{
88104
tabela: "agent_inbox_items",
89105
coluna: "kind",
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* INVOCAÇÃO DE IA SEM AGENTE DONO PRECISA ENTRAR NA AUDITORIA (issue #160).
3+
*
4+
* ## O defeito, reportado por @jmpo medindo a própria VPS
5+
*
6+
* `ai_invocations` é a fonte das telas de consumo e custo de IA. Ele achou a
7+
* tabela com **zero linhas** numa instalação com 176 mensagens em 24h, e o log
8+
* do contêiner repetindo `invalid input syntax for type uuid: ""`.
9+
*
10+
* O classificador de sentimento roda MESMO sem agente ativo — lê o agente só
11+
* para descobrir o threshold e cai no default quando não acha, o que está
12+
* certo. Mas auditava com `agent_id: agent?.id ?? ""`, e a coluna era
13+
* `uuid NOT NULL`.
14+
*
15+
* ## Por que passou tanto tempo
16+
*
17+
* O insert é **fire-and-forget**: `logInvocation` só faz `logger.warn` quando
18+
* falha. Ninguém vê erro, nada quebra na tela — o custo é pago ao provider e
19+
* simplesmente não aparece em lugar nenhum do CRM. E "sem agente ativo" não é
20+
* estado exótico: é o estado normal de quem ainda não publicou o agente.
21+
*
22+
* ## O que se guarda aqui
23+
*
24+
* O que o worker MANDA, não o que o banco aceita — o banco é vigiado pelo
25+
* `test:db`. Aqui a asserção é sobre a fronteira: `null` significa "não tem
26+
* dono" e tem que viajar como `null`, nunca como string vazia, que é a forma
27+
* que o Postgres recusa.
28+
*/
29+
import { describe, expect, it, vi, beforeEach } from "vitest";
30+
31+
const inserts: Array<Record<string, unknown>> = [];
32+
33+
vi.mock("@/lib/supabase/admin", () => ({
34+
createAdminClient: () => ({
35+
from: () => ({
36+
insert: (row: Record<string, unknown>) => {
37+
inserts.push(row);
38+
return Promise.resolve({ error: null });
39+
},
40+
}),
41+
}),
42+
}));
43+
44+
const { logInvocation } = await import("@/lib/ai/log-invocation");
45+
46+
/** `logInvocation` usa `queueMicrotask`; isto dá o tick para ele rodar. */
47+
async function drenar(): Promise<void> {
48+
await new Promise((r) => setTimeout(r, 0));
49+
}
50+
51+
const BASE = {
52+
organization_id: "11111111-1111-4111-8111-111111111111",
53+
conversation_id: null,
54+
message_id: null,
55+
invocation_kind: "sentiment_classify" as const,
56+
model: "anthropic/claude-haiku-4-5",
57+
prompt_tokens: 10,
58+
completion_tokens: 5,
59+
latency_ms: 42,
60+
cost_cents: 1,
61+
};
62+
63+
describe("auditoria de invocação de IA sem agente dono", () => {
64+
beforeEach(() => {
65+
inserts.length = 0;
66+
});
67+
68+
it("agent_id null viaja como null, nunca como string vazia", async () => {
69+
logInvocation({ ...BASE, agent_id: null });
70+
await drenar();
71+
72+
expect(inserts).toHaveLength(1);
73+
expect(
74+
inserts[0]!.agent_id,
75+
'string vazia numa coluna uuid faz o insert falhar em silêncio — foi assim que a tabela ficou vazia numa VPS com tráfego real',
76+
).toBeNull();
77+
expect(inserts[0]!.agent_id).not.toBe("");
78+
});
79+
80+
it('string vazia é normalizada para null pela PRÓPRIA função', async () => {
81+
// Este é o caso que de fato vigia o defeito. Medido: com `agent_id:
82+
// string | null`, um chamador escrevendo `?? ""` passa no typecheck E no
83+
// teste que só olha `null` — os dois gates cegos ao mesmo tempo. Quem
84+
// escreve na coluna uuid é esta função; é ela que tem de recusar a forma
85+
// que o banco recusa, senão o próximo chamador repete o erro.
86+
logInvocation({ ...BASE, agent_id: "" as unknown as null });
87+
await drenar();
88+
expect(inserts[0]!.agent_id).toBeNull();
89+
});
90+
91+
it("só espaço em branco também vira null", async () => {
92+
logInvocation({ ...BASE, agent_id: " " as unknown as null });
93+
await drenar();
94+
expect(inserts[0]!.agent_id).toBeNull();
95+
});
96+
97+
it("com agente, o id vai inteiro", async () => {
98+
// Guarda de vacuidade: sem este caso, um `agent_id: null` fixo passaria no
99+
// teste acima e apagaria a atribuição de custo de toda invocação com dono.
100+
logInvocation({ ...BASE, agent_id: "22222222-2222-4222-8222-222222222222" });
101+
await drenar();
102+
expect(inserts[0]!.agent_id).toBe("22222222-2222-4222-8222-222222222222");
103+
});
104+
105+
it("a linha entra mesmo sem agente — é custo pago que precisa aparecer", async () => {
106+
// A alternativa descartada na issue era "não logar quando não há agente":
107+
// trocaria um bug silencioso por uma lacuna silenciosa. Este caso é o que
108+
// impede alguém de aplicá-la depois achando que simplifica.
109+
logInvocation({ ...BASE, agent_id: null, cost_cents: 7 });
110+
await drenar();
111+
expect(inserts).toHaveLength(1);
112+
expect(inserts[0]!.cost_cents).toBe(7);
113+
});
114+
});

0 commit comments

Comments
 (0)