Skip to content

018 — Onboarding zero-state via conversa (write tools compostas em fluxo guiado)

Pós-wa-001, o casal já tem Household salvo no HouseholdRepository — mas com um único Member derivado do senderId WhatsApp (Ana). Falta tudo o que dá utilidade pro agente: o parceiro como segundo Member, renda fixa de cada um, custos recorrentes da casa e ao menos uma meta. Esse cenário cobre o happy path dessa primeira configuração, inteiramente via chat — sem tela de cadastro.

O agente conduz captura iterativa: pergunta uma coisa de cada vez, propõe via tool-call (confirmed:false, persisted:false), espera o “sim” do casal, confirma (mesmo operationId + confirm:true), avança pro próximo step. Cada step é uma write tool da família 013 (propose → confirm, idempotência por operationId). 018 não introduz pattern novo — compõe as write tools existentes (RegisterExpenseTool do 013, CreateGoalTool do 017) com as duas novas (AddMemberTool, AssignIncomeTool) num fluxo multi-turn coerente.

A composição vira o teste: o spec valida que um conjunto realista de turns produz um Household completo (2 members + 2 incomes + 1 expense + 1 goal). Cada tool individualmente já tem teste de invariante no seu cenário de origem; 018 testa a costura entre elas + dois edge cases novos que só fazem sentido em fluxo guiado:

  • opt-out: casal nega uma proposta — agente pula o step sem persistir nada;
  • idempotência cross-turn: casal duplica uma confirmação (rede flaky, refresh, LLM retry) — segunda tool-call com mesmo operationId vira no-op (herdado do 013).

Scenario: Onboarding completo zero-state em conversa multi-turn

Section titled “Scenario: Onboarding completo zero-state em conversa multi-turn”
  • Given um Household “Casa” em BRL com apenas Ana (criado pelo bootstrap wa-001) já persistido em HouseholdRepository

  • And BudgetRepository e GoalRepository vazios (list() size 0)

  • And um AgentChat configurado com as write tools addMember, assignIncome, registerExpense, createGoal apontando pros respectivos repositories

  • And o LLM mockado com script multi-turn (uma resposta por ask()) cobrindo: pergunta inicial → tool-call propose Bruno → confirm Bruno → pergunta renda Ana → propose Ana 8000 → confirm Ana → pergunta renda Bruno → propose Bruno 7000 → confirm Bruno → pergunta despesa → propose Aluguel → confirm Aluguel → pergunta meta → propose Lua de mel → confirm meta → fechamento

  • When o casal envia "vamos começar"

  • Then o agente responde em texto perguntando pelo parceiro (sem chamar tool)

  • And o casal manda "Bruno"

  • And o agente chama addMember({ name: "Bruno", operationId: "op-1" }) (preview, confirmed:false, persisted:false)

  • And Household.members ainda tem só Ana

  • And o casal manda "sim"

  • And o agente chama addMember({ name: "Bruno", operationId: "op-1", confirm: true }) (confirmed:true, persisted:true)

  • And householdRepo.load(household.id).members().length agora é 2 (Ana + Bruno)

  • And o agente prossegue: pergunta renda da Ana

  • And o casal manda "R$ 8000"

  • And o agente chama assignIncome({ member: "Ana", amount: 8000, currency: "BRL", operationId: "op-2" }) (preview)

  • And o casal confirma — agente chama assignIncome({ ..., operationId: "op-2", confirm: true }) (persiste)

  • And o agente pergunta renda do Bruno

  • And o casal manda "R$ 7000"

  • And o agente chama assignIncome({ member: "Bruno", amount: 7000, currency: "BRL", operationId: "op-3" }) (preview)

  • And o casal confirma — agente persiste

  • And o agente pergunta custos fixos

  • And o casal manda "aluguel R$ 3000, vence dia 5"

  • And o agente chama registerExpense({ nome: "Aluguel", expected: {amount:3000, currency:"BRL"}, categoria: "moradia", vencimento: 5, pagamento: "pix", operationId: "op-4" }) (preview)

  • And o casal confirma — agente persiste

  • And o agente pergunta sobre a meta do casal

  • And o casal manda "€6000 pra junho 2028, lua de mel"

  • And o agente chama createGoal({ name: "Lua de mel", target: {amount:6000, currency:"EUR"}, deadline: "2028-06-01", contributors: ["Ana","Bruno"], operationId: "op-5" }) (preview)

  • And o casal confirma — agente persiste

  • And o agente fecha com texto "onboarding completo — tudo configurado"

  • Then estado final observável nos repositories:

    • householdRepo.load(household.id).members().length é 2 (Ana + Bruno reusando id estável do bootstrap pra Ana, novo UUID pra Bruno)
    • householdRepo.load(household.id).monthlyIncome() é Money.of(15000, "BRL") (8000 Ana + 7000 Bruno)
    • budgetRepo.list() tem ao menos 1 entrada com 1 RecurringExpense (Aluguel)
    • goalRepo.list().length é 1 com Goal “Lua de mel” target €6.000 deadline 2028-06-01
  • And todas as tool calls com confirm:true retornaram ToolReceipt.persisted === true

Scenario: Casal nega uma proposta — agente pula o step

Section titled “Scenario: Casal nega uma proposta — agente pula o step”
  • Given o mesmo Household zero-state do scenario anterior (só Ana)
  • And agente com addMember configurado, no preview do addMember({name:"Bruno", operationId:"op-1"})
  • When o casal responde "não, deixa pra depois"
  • And o LLM mockado, no próximo turno, não chama nenhuma tool — só responde texto "ok, partindo pra renda" (transição manual sem confirm:true)
  • Then householdRepo.load(household.id).members().length continua 1 (Ana — Bruno não foi adicionado)
  • And o addMember tool log NÃO tem entrada confirmada pra op-1 (preview ficou pendurado, expira em memória da tool — sem efeito colateral)
  • And a próxima ask() do agente prossegue normalmente (fluxo não travou no opt-out)

Scenario: Idempotência cross-turn — casal duplica confirmação

Section titled “Scenario: Idempotência cross-turn — casal duplica confirmação”
  • Given o step assignIncome({member:"Ana", amount:8000, currency:"BRL", operationId:"op-2", confirm:true}) já confirmado e persistido (Ana = R$ 8.000 no repo)
  • When o casal manda mensagem duplicada (rede flaky, LLM retry) e o LLM mockado chama de novo assignIncome com mesmos args + mesmo operationId: "op-2" + confirm:true
  • Then a segunda call retorna ToolReceipt { confirmed: true, persisted: false } (idempotência herdada de 013)
  • And householdRepo.load(household.id).monthlyIncome() continua R$ 8.000 (não dobra pra R$ 16.000)
  • And householdRepo.save foi chamado uma única vez desde o início do scenario (na primeira confirmação)
  • Sem context novoagent/domain/tools/ ganha duas tools (AddMemberTool, AssignIncomeTool) seguindo o mesmo shape de RecordSpendTool/ContributeToGoalTool (013). Ports e adapters já existem (HouseholdRepository em 010).
  • Sem aggregate novoHousehold.addMember/assignIncome já existem desde 005. As tools são wrappers finos sobre esses métodos (gotcha 008: “tools são wrappers finos sobre aggregates, sem regra de negócio nova”).
  • Sem orquestrador novo no domínio — quem decide a ordem dos steps é o LLM via system prompt (fora do domínio). Spec mocka isso via fila de respostas; em prod o system prompt do agente codifica a sequência. AgentChat.ask continua stateless (gotcha 008: “histórico passa como input”).
  • Sem novo VO OnboardingFlow — fluxo não é entidade de domínio. É política de UX/LLM. Se aparecer caso “preciso pausar/retomar onboarding com state machine durável” aí promove a tracker. Hoje: working memory do agente (012) é suficiente.
src/contexts/agent/domain/tools/AddMemberTool.ts
AddMemberTool.create({
householdRepo: HouseholdRepository;
householdId: string;
}): AddMemberTool
addMemberTool.addMember(args: {
name: string; // fronteira EN — string opaca, tool resolve internamente via Member.create
operationId: string;
confirm?: boolean; // default false
}): Promise<ToolReceipt>
// preview shape: { name: string, memberCount: { before: number, after: number } }
// src/contexts/agent/domain/tools/AssignIncomeTool.ts
AssignIncomeTool.create({
householdRepo: HouseholdRepository;
householdId: string;
}): AssignIncomeTool
assignIncomeTool.assignIncome(args: {
member: string; // Member.name — lookup interno (gotcha 008)
amount: number;
currency: string;
operationId: string;
confirm?: boolean;
}): Promise<ToolReceipt>
// preview shape: {
// member: string,
// amount: { amount: number, currency: string },
// newMonthlyIncome: { amount: number, currency: string }
// }
// src/contexts/agent/domain/AgentChat.ts (já extendido em 013)
AgentChat.create({
model: LanguageModelV1;
tools: {
// read (008)
budget?: BudgetTool; goal?: GoalTool; feasibility?: FeasibilityTool;
// write (013)
recordSpend?: RecordSpendTool;
registerExpense?: RegisterExpenseTool;
contributeToGoal?: ContributeToGoalTool;
adjustExpense?: AdjustExpenseTool;
// 009
importInvoice?: ImportInvoiceTool;
// 017
createGoal?: CreateGoalTool;
// 018 (novos)
addMember?: AddMemberTool;
assignIncome?: AssignIncomeTool;
};
}): AgentChat
  • Onboarding é composição, não pattern novo — propose → confirm + idempotência por operationId foram cravados em 013. Aqui só somam-se duas tools novas (AddMemberTool, AssignIncomeTool) seguindo cópia carbono do shape. Spec valida costura — cada tool individual tem invariante validada no cenário de origem.
  • System prompt do LLM decide ordem dos steps, não o domínio — não existe OnboardingFlow aggregate ou Domain Service stateful. O LLM (real em prod, mock no spec) lê a working memory (012) + system prompt + última mensagem e decide qual tool chamar a seguir. Domínio fica stateless. Quando aparecer caso “casal abandona onboarding no meio e quer retomar amanhã”, working memory já cobre — WorkingMemory.onboardingProgress: "members-done" é uma string no JSON LLM-edited.
  • AddMemberTool aceita name: string na fronteira LLM, gera UUID interno — gotcha 008 + 013: “UUID não vaza pra fronteira LLM”. Household.addMember(Member.create({name})) cuida disso. Member fica com id novo (não tenta “casar” com Member já existente — addMember é literal “adiciona um novo”). Edge case “casal manda mesmo nome duas vezes” não é coberto aqui (LLM segura no system prompt).
  • AssignIncomeTool faz lookup por namehousehold.members().find(m => m.name === args.member). Mesmo pattern de ContributeToGoalTool (013). Se name não bater, tool throw (happy path assume LLM passa nome existente — depende do step anterior ter persistido o Member).
  • Preview da AddMemberTool mostra memberCount.{before,after}, não members[] completo — UX mostra “vai ficar 2 members”. Lista completa fica pra read tool dedicada se aparecer demanda. Mantém preview compacto e POJO trivial.
  • Preview da AssignIncomeTool mostra newMonthlyIncome — UX já consegue mostrar “renda total vai pra R$ 15.000”. Reusa Household.monthlyIncome() (sem period, gotcha 014: continua valendo) na preview. Sem mutação — tool clona ou simula? Mais simples: chama o método antes de assignIncome, calcula manualmente o novo total (delta). Detalhe de impl — spec só valida o número final.
  • Opt-out é ausência de confirm:true — quando o casal nega, o LLM não chama a tool com confirm:true. Sem mecanismo de “rejeitar explicitamente”. O preview pendente expira em memória da tool (no Map<operationId, ToolReceipt>); proxima call com operationId diferente é operação nova. Reaproveita o gotcha de 013 “limpeza/expiração de previews é detalhe de impl”.
  • Idempotência cross-turn é exatamente o do 013 — mesmo operationId + confirm:true na segunda call → no-op. Aqui o caso de uso é “casal duplicou sim/sim” (rede ruim, LLM retry). Comportamento já testado em 013 — replicado aqui pra cobrir composição.
  • Sem rollback transacional cross-tool — se a 4ª tool falhar (ex: registerExpense lança erro), os 3 primeiros steps já persistiram (Bruno adicionado, rendas atribuídas). Aceito como trade-off (gotcha 013: “transactions cross-aggregate fora do escopo”). Recuperação é via novo fluxo no chat (“quero remover o Bruno que adicionei sem querer” — fora de escopo).
  • Spec usa Household já com Ana criada (não recria do zero) — simula o estado pós-wa-001. Reflete realidade: 018 começa onde wa-001 termina, não cobre o bootstrap inicial (esse mora em wa-001).
  • CreateGoalTool assumido disponível (017 em paralelo) — import aspiracional, igual RegisterExpenseTool no 013 era aspiracional na época. Quando 017 implementa, esse spec passa direto.
  • Cenário 005 (Household) — sem mudança de contract. Tools novas chamam API existente (addMember, assignIncome).
  • Cenário 013 (família write tools)AddMemberTool e AssignIncomeTool ampliam a família. Padrão (preview shape, ToolReceipt, log por operationId) idêntico — sem refactor do 013.
  • Cenário 017 (CreateGoalTool — paralelo) — 018 consome CreateGoalTool. Se 017 mudar shape do schema, 018 mock LLM precisa update do args (mas comportamento testado é o de 018).
  • Cenário wa-001 (onboarding via WhatsApp) — fluxo 018 roda dentro do AgentChat.ask invocado pelo WhatsAppMessageRouter. wa-001 cria o Household zero-state; 018 preenche. wa-001 não precisa touchup — é cliente do 018, não dependente.
  • Cenário 012 (chat persistido) — 018 não toca AgentMemory direto, mas em prod o fluxo onboarding cabe num thread persistido. Spec não exercita memory (mantém escopo).
  • Cenário “onboarding com múltiplos parceiros” (poliamor, dependentes) — 018 cobre casal hetero/homo de 2 members. Adicionar 3º Member é mesma AddMemberTool, mas o system prompt do LLM precisa cobrir o caso. Domínio já aguenta (Household aceita N members desde 005).
  • Validação de plausibilidade dos valores — casal manda "R$ 80000" (errou um zero a mais)? Tool persiste se LLM confirmar. Sanity check fica pra read tool HouseholdSanity futura ou pro próprio system prompt.
  • Onboarding parcial (skip de step “registro despesa”) — system prompt do LLM decide se pula. Spec não exercita — só o opt-out de um step (scenario 2). Pulamento sequencial fica pro LLM em prod.
  • Onboarding com bootstrap em paralelo a wa-001 — wa-001 já cria Household; 018 não recria. Onboarding via outro canal (web sem WhatsApp) precisaria de bootstrap próprio — cenário futuro.
  • Working memory sync no meio do onboardingWorkingMemory.onboardingProgress evolui conforme steps confirmam. Comportamento implementado via updateWorkingMemory (Mastra default) ficou em 012; 018 não duplica spec disso. Em prod o LLM mantém estado natural via memory tools.
  • Retomada do onboarding interrompido — casal para no meio, volta no dia seguinte. Working memory de 012 + system prompt cuidam. Spec não exercita (single-session).
  • Rollback de step confirmado — “removi o Bruno sem querer, desfaz” — fora do escopo. Tools de remoção (RemoveMemberTool, etc) entram em cenário próprio se aparecer dor.
  • Multi-currency no onboarding — assume household em BRL. Casal expat com renda em EUR/USD entra com cenário próprio (ADR single-currency por Household).
  • Cancelamento explícito (“cancelar onboarding inteiro”) — sem op de “undo onboarding”. Working memory marca completed; nova run cria novo Household se necessário. Fora do escopo.

Implementar AddMemberTool + AssignIncomeTool em src/contexts/agent/domain/tools/ (cópia carbono de RecordSpendTool/ContributeToGoalTool adaptada pra HouseholdRepository). Estender AgentChat.create({tools}) pra aceitar addMember/assignIncome opcionais. Atualizar agent/domain/index.ts (barrel). System prompt do agent em prod codifica a sequência onboarding — fora do escopo do spec. Tier e2e (Playwright) dirige fluxo via testids tool-preview + confirm-tool (gotcha 013).