feat(api): show the autopilot the message it sent (#128) - #176
Conversation
The EMAIL and WHATSAPP autopilots decided on conversations they could only see half of. Dispatch stores the collection notice as flat `channelData.messageBody`, while the ingest paths build their thread from `channelData.emailThread` — a key dispatch never writes. The thread therefore always started empty, so the agent's first view of a conversation was the customer's reply. It could say "escríbenos por WhatsApp" but never cite the link, which only ever existed in that notice, and could not answer "¿de qué trata esto?" at all. Prepend the notice at decision time via a shared `buildThreadWithOpener` in `@qcobro/common`, rather than seeding it into the stored thread: the notice stays in one place, in-flight gestiones are fixed without a backfill, and the console does not render it twice. `generateGestionInsight`, which had its own copy of this prepend, now uses the same helper. Also: - the engine persists the rendered EMAIL subject, which only the manual dispatch path had been keeping - the prompt renderer includes a message's subject, so the notice's subject line reaches the model - an agent reply falls back to the subject we sent; the old fallback resolved to the first inbound message - eval scenarios seed the same notice, rendered against the scenario account — the harness had the identical blind spot, which is why this went unnoticed - WHATSAPP passes `referenceDate`, as EMAIL always has, so "el viernes" can resolve to a dated PaymentPromise No truncation or context window is introduced; there was none, and the specs now state that guarantee rather than leaving it implicit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012893PhmVYDiGkL8p7hyavx
Passing `referenceDate` into `decide` did nothing: WhatsApp uses `whatsAppAutopilot.ts`'s own prompt builder, which never read the field and carried neither of the two date lines EMAIL has. So "le pago el viernes" was still sent to the model with no anchor date and no format rule. That mattered because `emailAutopilotDecisionSchema` types `dueDate` as a bare `z.string().optional()` — a literal "el viernes" validates and is written straight through as `intentMetadata.promisedDate` on a real PaymentPromise. The test added alongside it asserted only that the field reached `decide`, so it passed either way. A green test plus a spec scenario asserting behaviour that does not exist is worse than the original gap, since it closes the question for future readers. Add the two date lines to the WhatsApp prompt builder, and test the prompt text actually sent to the model — for both channels — rather than the request handed to `decide`. Verified by reverting the fix and watching the new test fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012893PhmVYDiGkL8p7hyavx
Review follow-up — critical finding fixed (d0c2521)A code review flagged that the WhatsApp doesn't use That lands in persisted data. The test I'd written asserted only that the field reached Fixed by adding the two date lines to the WhatsApp prompt builder, and by testing the prompt text actually sent to the model, for both channels, rather than the request handed to 548 tests pass in Three further findings, not fixed hereRecording them so they aren't lost. None block this PR in my view, but the first is worth a look before merge if you disagree.
Happy to fold any of these in — say which. 🤖 Generated with Claude Code |
WhatsApp eval openers rendered empty. A stored whatsAppConfig.messageBody is a
Meta-approved template whose named parameters are lowercase snake_case, while
the account context is camelCase — renderWhatsAppTemplate maps between them and
plain Handlebars does not. So "Estimado {{first_name}}, su saldo es
{{outstanding_balance}}." seeded as "Estimado , su saldo es .", grading the
agent against a notice production never sends and defeating the eval half of
this change for that channel.
The reply subject used `??` where it needed `||`. `inboundEmailSchema` types
subject as optional, so a reply carrying `Subject:` with an empty value parses
as "" — which `??` keeps, sending a bare "Re:" and skipping the notice-subject
fallback this change adds, in exactly the case it was added for.
referenceDate was the UTC calendar day. `nowIso.slice(0, 10)` ignored the
workspace's configured timezone, so a reply at 21:30 in UTC−4 already read as
tomorrow and "mañana" resolved a day early onto a PaymentPromise. Both gestión
views now carry `workspaceTimezone`; both Prisma loaders already read the
settings row for currency and locale, so this adds no query. `DEFAULT_TIMEZONE`
in @qcobro/common mirrors the Prisma column default for the lazily-seeded case.
Each fix has a test that was confirmed to fail without it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012893PhmVYDiGkL8p7hyavx
All four review findings now fixed (e6adf2d)Following on from the critical one in d0c2521 — the remaining three are in. WHATSAPP eval openers rendered empty (
VerificationEach fix has a test, and I checked each one actually catches its bug by reverting the fix and watching it go red:
553 pass in The timezone tests are worth a look — they assert on instants that are genuinely a different date in the two zones (00:30 UTC on the 27th is 20:30 on the 26th in Santo Domingo), so they'd fail under the old UTC computation rather than passing coincidentally. 🤖 Generated with Claude Code |
Closes #128.
The gap
Dispatch stores the collection notice as flat
channelData.messageBody. Both ingest paths build their thread fromchannelData.emailThread/whatsAppThread— keys dispatch never writes. So the thread always initialized empty, and the agent's first view of a conversation was the customer's reply.The production "mora temprana" prompt tells the agent to redirect payment questions to WhatsApp, but the link exists only in that notice. The agent could say "escríbenos por WhatsApp" and never cite it.
WHATSAPP had the identical gap, and so did the eval harness — which is why it went unnoticed: the tool used to test agents reproduced the same blind spot.
Before / after, real model, same prompt and account
Customer: ¿De qué trata esto?
Before — no link, balance invented from context:
After — answers the question, cites the real link:
Approach
The notice is prepended at decision time by a shared
buildThreadWithOpenerin@qcobro/common, not seeded into the stored thread. #128 proposed the latter; it costs more than it saves:GestionDetail.tsx:363/:448already rendermessageBodyabove the thread, so seeding shows the notice twiceGestionDetail.tsx:201requests an AI analysis when the thread is non-empty — a dispatch-time seed makes that fire on conversations that haven't happenedgenerateGestionInsight.tsalready had its own copy of this prepend; it now uses the same helper, so an insight and a reply can't be built from different views.Also in here
subject; previously onlybodywas renderedreferenceDate, as EMAIL always has — without it "el viernes" couldn't resolve to a datedPaymentPromiseTruncation question, answered
There is none. Both prompt builders render the whole thread every turn; the only
.slice(calls extract JSON from fenced responses. The only bound is the reply cap (default 3). That was an accident of the implementation, so the specs now state it — a future windowing change has to be deliberate.Testing
mods/common, 541 inmods/apiserver, including a continuity test across three replies asserting the notice stays at index 0 and nothing earlier is droppedemail.integration.test.tsrun against real Postgres — both pass, covering the new dispatch-subject assertioncreateIngestEmailReply, with the send and persistence stubbedNote for review
whatsapp.integration.test.tshas one pre-existing failure on main —4,800vs4800, a stale expectation against the money-formatting behaviour (see the activemoney-workspace-localechange). Untouched by this PR.Follow-up, not code
Facts that exist only in the outbound template (the WhatsApp link) should also be stated in
systemPrompt— it's the one field guaranteed to reach the model every turn and it survives someone editing the message template. This PR makes the notice visible; it doesn't make it the right home for a load-bearing fact. Recorded indesign.md.Eval scenario expectations were authored against an agent that couldn't see the notice, so the suite needs a re-run and a triage pass before its results mean anything again.
🤖 Generated with Claude Code
https://claude.ai/code/session_012893PhmVYDiGkL8p7hyavx