CedarCopilot wants to merge 3 commits into staging from fix/live-thread-recipients
Live on prod, no production signal yet
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.
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.
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.
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.
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.
Recipients reach the agent (readLiveThreadTool)
to / cc / bcc per message, and the tool description now tells the agent to use them when asked who is on a thread.Name <email> strings rather than {name, email} objects. The consumer is a model; the flat form is cheaper and harder to misread.[], 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.
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
Libra has not measured any production surfaces for this change yet.