Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(chat): stop leaking raw JSON into Slack messages on malformed final_answer

open#2852CedarCopilot

CedarCopilot wants to merge 1 commit into staging from fix/sherlock-slack-chat-malformed-json-20260920-154713

Not deployedTimeline and evidence
  1. Opened
    Sep 20, 2026, 5:45 PM
  2. Sep 20, 2026, 5:51 PM
  3. Merged, live on staging, live on prod, observed
    Pending
  4. Not deployed

    Not deployed

    Pending

Libra has no production signal for this change yet because it has not deployed. Libra checks hourly for 3 days after each deploy.

Sherlock investigation

Isabelle reported an intermittent malformed Slack message where the literal JSON payload , including a broken "buttons":[...] array (a dropped "label" key) , showed up as the visible message text instead of rendering as prose + real buttons.

Root cause

runChatAgent (slack-bot-chat.ts) asks the chat agent to hand-write a JSON blob inside <final_answer> tags, then does a bare JSON.parse(rawText). When that JSON is invalid , e.g. the agent quotes an exact customer phrase with plain double quotes (raised the "tasks don't auto-complete..." issue), which breaks the JSON string , the catch block falls through to using the entire raw text, including the literal "buttons":[...] array, as the visible Slack message. No repair, no retry, no safe fallback.

Confirmed live against today's actual chat history for : pulled the two real persisted responses to her "stale tasks" question, ran them through the literal production JSON.parse call, and both threw at the exact unescaped-quote position , reproducing the bug class exactly.

Fix

Replace the manual JSON.parse with a tool-free, schema-constrained structuring pass (chatResponseStructuringAgent, new export in chat-agent.ts) that reshapes the main agent's draft text into SlackResponseSchema via Mastra's structuredOutput. Schema-constrained decoding can't emit invalid JSON, so it repairs the common failure modes (unescaped inner quotes, a double-encoded text field, a truncated field) instead of failing outright, and errorStrategy: 'fallback' guarantees a clean generic message for whatever still doesn't fit , never a raw-text leak.

Deliberately a separate call with no tools attached (not bolted onto the same .generate() call as the tool-calling agent) , combining tools and structuredOutput in one call is unsupported by some model providers (Mastra's docs flag this for Gemini 2.5 + function calling). The main tool-calling loop is unchanged.

Also loosens ActionButtonSchema.style from .optional().default('default') to .optional() , the .default() made the schema's own inferred type require style, but the real structuredOutput result type doesn't guarantee it's present, and the only consumer (buildBlockKitBlocks) already treats a missing style identically to 'default'.

Verification

  • pnpm --filter @zero/server run types , clean.
  • vitest run on the 4 affected test files (35 tests) , all pass, including an updated mock in surface-context-parity.test.ts for the new structuring call.
  • Live script (no mocks) run against the real chatResponseStructuringAgent, exercising:
    1. Today's actual malformed payload (unescaped inner quotes) , recovered, all 3 button labels correct.
    2. The second real malformed payload from the same thread , same result.
    3. A reconstruction of the exact reported bug (dropped "label" key) , recovered with a valid, functional button (falls back to a generic label only for the one button whose original label was genuinely unrecoverable, rather than leaking JSON or crashing).
    4. Plain prose with no JSON at all (the ordinary non-interactive-turn case) , passes through unchanged.

Known follow-up (not in this PR)

imessage-bot-chat.ts and sms-bot-chat.ts have the identical hand-rolled JSON.parse pattern. Same bug class, same fix would apply, but out of scope for this report.

šŸ¤– Generated with Claude Code

Retrigger

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/agents/chat-agent.tsno production surface mapped
  • apps/server/src/mastra/operations/headless-response-schemas.tsno production surface mapped
  • apps/server/src/mastra/routeHandlers/slack/slack-bot-chat.tsno production surface mapped
  • apps/server/src/mastra/workflows/chat/__tests__/surface-context-parity.test.tsno production surface mapped