Skip to content

feat(api): show the autopilot the message it sent (#128) - #176

Merged
psanders merged 4 commits into
mainfrom
fix/autopilot-thread-opener
Sep 11, 2026
Merged

feat(api): show the autopilot the message it sent (#128)#176
psanders merged 4 commits into
mainfrom
fix/autopilot-thread-opener

Conversation

@psanders

Copy link
Copy Markdown
Member

Closes #128.

The gap

Dispatch stores the collection notice as flat channelData.messageBody. Both ingest paths build their thread from channelData.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:

¡Hola! Somos Créditos Demo. Te escribimos para recordarte sobre tu crédito pendiente. Actualmente tienes un saldo de 4800 y 12 días de atraso. Si tienes preguntas sobre tu pago o necesitas datos bancarios, por favor contáctanos por WhatsApp para ayudarte de inmediato.

After — answers the question, cites the real link:

Estimada Ana, este correo es un recordatorio sobre el saldo vencido de su préstamo con Créditos Demo. Para cualquier pregunta o para coordinar su pago, por favor escríbanos a nuestro WhatsApp: https://wa.me/18095550123

Approach

The notice is prepended at decision time by a shared buildThreadWithOpener in @qcobro/common, not seeded into the stored thread. #128 proposed the latter; it costs more than it saves:

  • GestionDetail.tsx:363/:448 already render messageBody above the thread, so seeding shows the notice twice
  • GestionDetail.tsx:201 requests an AI analysis when the thread is non-empty — a dispatch-time seed makes that fire on conversations that haven't happened
  • it needs a backfill, so it wouldn't reach any gestión currently in flight
  • it duplicates the notice into two keys that then have to agree

generateGestionInsight.ts already 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

  • the engine persists the rendered EMAIL subject — only the manual dispatch path was keeping it
  • the prompt renderer includes a message's subject; previously only body was rendered
  • 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's synthetic account
  • adjacent: WHATSAPP now passes referenceDate, as EMAIL always has — without it "el viernes" couldn't resolve to a dated PaymentPromise

Truncation 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

  • 250 pass in mods/common, 541 in mods/apiserver, including a continuity test across three replies asserting the notice stays at index 0 and nothing earlier is dropped
  • email.integration.test.ts run against real Postgres — both pass, covering the new dispatch-subject assertion
  • the before/after above is the real Gemini autopilot through createIngestEmailReply, with the send and persistence stubbed

Note for review

whatsapp.integration.test.ts has one pre-existing failure on main — 4,800 vs 4800, a stale expectation against the money-formatting behaviour (see the active money-workspace-locale change). 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 in design.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

psanders and others added 2 commits September 11, 2026 13:02
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
@psanders

Copy link
Copy Markdown
Member Author

Review follow-up — critical finding fixed (d0c2521)

A code review flagged that the referenceDate addition in this PR did nothing at runtime, and it was right.

WhatsApp doesn't use emailAutopilot.ts's prompt builder — it has its own in whatsAppAutopilot.ts, which never read req.referenceDate and carried neither of EMAIL's two date lines. So referenceDate reached decide and was dropped on the floor: "le pago el viernes" still went to the model with no anchor date and no format rule.

That lands in persisted data. emailAutopilotDecisionSchema types dueDate as a bare z.string().optional(), so a literal "el viernes" validates and is written straight through as intentMetadata.promisedDate on a real PaymentPromise.

The test I'd written asserted only that the field reached decide, so it passed either way — which is the worse half of the bug. A green test plus a spec scenario asserting behaviour that doesn't exist closes the question for future readers.

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 decide. Confirmed the new test catches it: reverting the one-line fix turns WHATSAPP: the reference date reaches the model red.

548 tests pass in mods/apiserver.

Three further findings, not fixed here

Recording 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.

  1. runAutopilotEvaluation.ts:45 — the WHATSAPP eval opener renders to empty placeholders. It uses renderTemplate, but a stored whatsAppConfig.messageBody is a Meta template with lowercase snake_case parameters; production dispatch uses renderWhatsAppTemplate, which maps first_namefirstName first. So a WHATSAPP opener of "Estimado {{first_name}}, su saldo es {{outstanding_balance}}." seeds as "Estimado , su saldo es ." — the eval half of this PR is defeated for that channel. Fix is a type branch; EMAIL is unaffected.

  2. referenceDate is the UTC date, not the workspace's (ingestWhatsAppMessage.ts, ingestEmailReply.ts). nowIso.slice(0, 10) ignores the configured timezone (America/Santo_Domingo, UTC−4). A reply at 21:30 local Thursday already reads as Friday, so "mañana" lands a day late. Pre-existing on EMAIL; this PR propagates the same computation to WhatsApp.

  3. ingestEmailReply.ts:152 uses ?? where it wants ||. inboundEmailSchema types subject as z.string().optional(), so a reply carrying an empty Subject: parses as "". ?? doesn't skip "", so the chain stops there and the reply goes out as bare "Re:" — defeating the notice-subject fallback this PR adds, in exactly the case it was added for. Two-character fix.

Happy to fold any of these in — say which.

🤖 Generated with Claude Code

https://claude.ai/code/session_012893PhmVYDiGkL8p7hyavx

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
@psanders

Copy link
Copy Markdown
Member Author

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 (runAutopilotEvaluation.ts). A stored whatsAppConfig.messageBody is a Meta template with lowercase snake_case parameters; the account context is camelCase. renderWhatsAppTemplate maps between them, plain Handlebars doesn't — so "Estimado {{first_name}}, su saldo es {{outstanding_balance}}." seeded as "Estimado , su saldo es .". Now branches on agent.type.

??|| on the reply subject (ingestEmailReply.ts). An empty Subject: parses as "", which ?? keeps — sending a bare "Re:" and skipping the notice fallback this PR adds, in exactly the case it was added for.

referenceDate is now the workspace's calendar day, not UTC's. workspaceTimezone rides on both gestión views; both Prisma loaders already read the settings row for currency/locale, so this adds no query. DEFAULT_TIMEZONE in @qcobro/common mirrors the Prisma column default for the lazily-seeded case.

Verification

Each fix has a test, and I checked each one actually catches its bug by reverting the fix and watching it go red:

Fix Test Red without the fix
WhatsApp prompt reads referenceDate whatsAppAutopilot.test.ts
WhatsApp eval opener rendering runAutopilotEvaluation.test.ts
?? → ` `
Workspace timezone ingestEmailReply.test.ts, ingestWhatsAppMessage.test.ts

553 pass in mods/apiserver, 250 in mods/common. email.integration.test.ts passes against real Postgres.

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

https://claude.ai/code/session_012893PhmVYDiGkL8p7hyavx

@psanders
psanders merged commit f98c8bd into main Sep 11, 2026
2 checks passed
@psanders
psanders deleted the fix/autopilot-thread-opener branch September 11, 2026 23:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Give the EMAIL autopilot visibility into the initial message and full conversation thread

1 participant