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
operationIdvira 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 emHouseholdRepository -
And
BudgetRepositoryeGoalRepositoryvazios (list()size 0) -
And um
AgentChatconfigurado com as write toolsaddMember,assignIncome,registerExpense,createGoalapontando 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.membersainda 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().lengthagora é 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 1RecurringExpense(Aluguel)goalRepo.list().lengthé 1 com Goal “Lua de mel” target €6.000 deadline 2028-06-01
-
And todas as tool calls com
confirm:trueretornaramToolReceipt.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
addMemberconfigurado, no preview doaddMember({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 semconfirm:true) - Then
householdRepo.load(household.id).members().lengthcontinua 1 (Ana — Bruno não foi adicionado) - And o
addMembertool log NÃO tem entrada confirmada praop-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
assignIncomecom mesmos args + mesmooperationId: "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.savefoi chamado uma única vez desde o início do scenario (na primeira confirmação)
Modelo
Section titled “Modelo”- Sem context novo —
agent/domain/tools/ganha duas tools (AddMemberTool,AssignIncomeTool) seguindo o mesmo shape deRecordSpendTool/ContributeToGoalTool(013). Ports e adapters já existem (HouseholdRepositoryem 010). - Sem aggregate novo —
Household.addMember/assignIncomejá 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.askcontinua 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.
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.tsAssignIncomeTool.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; };}): AgentChatDecisões de design
Section titled “Decisões de design”- Onboarding é composição, não pattern novo — propose → confirm + idempotência por
operationIdforam 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
OnboardingFlowaggregate 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. AddMemberToolaceitaname: stringna 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).AssignIncomeToolfaz lookup por name —household.members().find(m => m.name === args.member). Mesmo pattern deContributeToGoalTool(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
AddMemberToolmostramemberCount.{before,after}, nãomembers[]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
AssignIncomeToolmostranewMonthlyIncome— UX já consegue mostrar “renda total vai pra R$ 15.000”. ReusaHousehold.monthlyIncome()(sem period, gotcha 014: continua valendo) na preview. Sem mutação — tool clona ou simula? Mais simples: chama o método antes deassignIncome, 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 comconfirm:true. Sem mecanismo de “rejeitar explicitamente”. O preview pendente expira em memória da tool (noMap<operationId, ToolReceipt>); proxima call comoperationIddiferente é 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:truena 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:
registerExpenselanç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
Householdjá 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). CreateGoalToolassumido disponível (017 em paralelo) — import aspiracional, igualRegisterExpenseToolno 013 era aspiracional na época. Quando 017 implementa, esse spec passa direto.
Impacto em outros cenários
Section titled “Impacto em outros cenários”- Cenário 005 (Household) — sem mudança de contract. Tools novas chamam API existente (
addMember,assignIncome). - Cenário 013 (família write tools) —
AddMemberTooleAssignIncomeToolampliam 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.askinvocado peloWhatsAppMessageRouter. 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
AgentMemorydireto, mas em prod o fluxo onboarding cabe num thread persistido. Spec não exercita memory (mantém escopo).
Fora de escopo
Section titled “Fora de 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 toolHouseholdSanityfutura 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 onboarding —
WorkingMemory.onboardingProgressevolui conforme steps confirmam. Comportamento implementado viaupdateWorkingMemory(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.
Próximo passo
Section titled “Próximo passo”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).