Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(mail): read-live-email-thread keeps the To and Cc lists

merged#2647CedarCopilot

CedarCopilot wants to merge 3 commits into staging from fix/live-thread-recipients

Live on prod, no production signal yetTimeline and evidence
  1. Opened
    Sep 1, 2026, 4:01 PM
  2. Sep 1, 2026, 9:25 PM
  3. Merged
    Sep 1, 2026, 10:49 PM
  4. Live on prod
    Sep 1, 2026, 10:49 PM
  5. Observed 3 days
    Sep 1, 2026, 10:49 PM
  6. Pipelines steady after this deploy
    Sep 1, 2026, 10:49 PM
  7. Unobserved

    Live on prod, no production signal yet

    Sep 4, 2026, 10:42 PM

Behaviors Libra is checking

Live-thread reads omit the no-sender@unknown parser sentinel from To, Cc, and Bcc lists so malformed or blank headers are not shown as invented participants.Degradedlow confidence

Strict CloudWatch fallback found 10 prod failure log lines for mirror.upsertEmailThread failed (Failed query: \n UPDATE crm_thread_labels tl\n SET latest_message_at = t.latest_message_at\n FROM crm_email_threads t\n WHERE t.thread_id = tl.thread_id\n AND tl.thread_id = $1\n AND.

prod, checked Sep 4, 2026, 10:42 PM
Live-thread recipient lists are exposed as flat display strings using Name <email> when a name exists and a bare email address otherwise, with missing headers represented as empty arrays.Degradedlow confidence

Strict CloudWatch fallback found 10 prod failure log lines for mirror.upsertEmailThread failed (Failed query: \n UPDATE crm_thread_labels tl\n SET latest_message_at = t.latest_message_at\n FROM crm_email_threads t\n WHERE t.thread_id = tl.thread_id\n AND tl.thread_id = $1\n AND.

prod, checked Sep 4, 2026, 10:42 PM
Reading a live email thread returns each message’s To, Cc, and Bcc recipients alongside the sender and message preview fields.Not checked
prod
The mail action=read-thread response preserves each message’s To, Cc, and Bcc recipient lists instead of stripping them through a narrower response schema.Not checked
prod

Libra has verdicts on 0 of 4 tracked behaviors on prod; 4 are still being checked. Libra has 2 low-confidence degraded verdicts it is still confirming. Libra checks hourly for 3 days after each deploy.

The bug

Reported by Sam (SimCare) in #cedar-simcare. He asked the chat agent to "invite everyone that was on the email they sent". The agent read the thread and replied:

The email was sent only from Danielle, with no other recipients or CC'd contacts.

The email header, visible on the same screen, read To: <email>, Cc: <email>, <email>.

The model was not hallucinating. This is the entire tool result it was handed (from the production run, thread-1788301725961-696c0p4):

{"success": true, "readThread": {"subject": "Research", "threadId": "1a0591c8ba88b853", "messageCount": 1,
  "messages": [{"id": "1a0591c8ba88b853", "date": "2026-08-31T18:36:57.000Z",
    "from": "<email>", "unread": false, "snippet": "...", "subject": "Research"}]}}

No to, no cc. It reported a missing field as a missing person.

The same call ran one turn earlier, which is why the calendar invite the agent proposed had only Danielle and Vrishank on it. The invite was wrong from the start, not just the follow-up answer.

Cause

readLiveThreadTool projected each message down to six fields and dropped the recipients:

const messages = (thread.messages ?? []).map((m) => ({
  id: m.id, from: m.sender?.email, subject: m.subject,
  date: m.receivedOn, snippet: m.snippet, unread: m.unread,
}));

The data was already in hand. ParsedMessage carries to, cc, and bcc (apps/server/src/types.ts:130-132), and the Gmail driver parses both headers properly (lib/driver/google.ts:4438-4457). The tool's own output schema had no slot for them, so they never left the function.

Changes

Recipients reach the agent (readLiveThreadTool)

  • Emits to / cc / bcc per message, and the tool description now tells the agent to use them when asked who is on a thread.
  • Recipients are Name <email> strings rather than {name, email} objects. The consumer is a model; the flat form is cheaper and harder to misread.
  • Absent headers give [], not undefined, so "nobody else was on it" and "the field was not populated" stop looking identical.

The sentinel is filtered out (readLiveThreadTool, email-utils, drafting)

parseAddressList does not return an empty list for a degenerate header. It substitutes FALLBACK_SENDER, verified directly against the parser:

""                         -> [{"name":"","email":"no-sender@unknown"}]
"   "                      -> [{"name":"","email":"no-sender@unknown"}]
"garbage <<<"              -> [{"name":"","email":"no-sender@unknown"}]
"undisclosed-recipients:;" -> []

The To path in the driver reaches all three: it filters headers on typeof v === 'string', so an empty-string To: survives into the parser. The Cc path pre-filters blank headers but still admits malformed ones. Forwarding that sentinel would trade this bug for a worse one, inventing a participant instead of hiding a real one.

The sentinel is now exported as FALLBACK_SENDER_EMAIL from email-utils.ts, where it is produced. drafting.ts had already hand-rolled a local PARSE_FALLBACK_EMAIL = 'no-sender@unknown' against the same hazard; it now imports the shared constant instead of keeping a second copy of the literal.

One schema, not two (mailTool)

ReadThreadDataSchema now derives from ReadLiveThreadOutputSchema via .omit() instead of hand-re-declaring the message shape. That duplicate is how the two drifted, and it would have re-dropped the recipients at the external MCP boundary.

Tests

liveMailTools.test.ts and mailTool.live-actions.test.ts updated for the new shape, plus three new cases: Sam's exact regression (a Cc present on the message must survive), empty-list-not-undefined, and the sentinel filter. Each fails against the code it guards.

Assertions are against the whole tool result with toEqual, not plucked fields behind a cast. The contract is the thing under test, so a change that preserves cc but drops a sibling fails here too.

Test Files  18 passed
Show production surfaces and changed-file mapping

Production surfaces

Libra has not measured any production surfaces for this change yet.

Changed files → surfaces

  • apps/server/src/mastra/tools/mail/__tests__/liveMailTools.test.tsno production surface mapped
  • apps/server/src/mastra/tools/mail/__tests__/mailTool.live-actions.test.tsno production surface mapped
  • apps/server/src/mastra/tools/mail/mailTool.tsno production surface mapped
  • apps/server/src/mastra/tools/mail/readLiveThreadTool.tsno production surface mapped
  • apps/server/src/services/drafting/drafting.tsno production surface mapped
  • apps/server/src/services/mail/lib/email-utils.tsno production surface mapped