Skip to content

fix(protocol): fail-soft on malformed Claude assistant transcript rows#196

Open
Kunde21 wants to merge 5 commits into
happier-dev:devfrom
Kunde21:fix/transcript-unparsed-agent-message
Open

fix(protocol): fail-soft on malformed Claude assistant transcript rows#196
Kunde21 wants to merge 5 commits into
happier-dev:devfrom
Kunde21:fix/transcript-unparsed-agent-message

Conversation

@Kunde21

@Kunde21 Kunde21 commented Jul 10, 2026

Copy link
Copy Markdown

Problem

Claude unified-terminal transcripts can contain known row types whose payload body is incomplete, such as an assistant row without message, with a missing nested role, or with content: null. The strict known branch rejects these shapes, while the previous fallback also rejected every known type, causing the entire transcript record to fail validation.

Goal and implementation

Keep the shared transcript envelope strict while making provider payload bodies forward-compatible:

  • strict, well-formed known rows still match the known discriminated union first;
  • malformed known payload bodies and future output types fall through to an opaque branch;
  • malformed shared envelope fields, such as a non-string uuid, still fail validation;
  • downstream behavior remains owned by the existing message-role and normalization layers.

The obsolete known-type collision set was removed, and the fallback is named Opaque rather than Unknown because it can intentionally contain either a future type or a known type whose body failed strict parsing.

Observable behavior

  • A message-less or null-content assistant classified by the CLI as an event is accepted by the protocol and intentionally filtered by the UI.
  • An assistant with recoverable text but a missing nested role is accepted and renders that text when classified as agent.
  • A future output type remains visible through the existing unsupported-output fallback.
  • A well-formed assistant continues to produce rich normalized text.
  • Direct Claude transcript items carry the same validated messageRole metadata as synced messages, so non-renderable event rows are filtered consistently in both paths.
  • Direct-session import/takeover preserves that role when committing managed storage, including encrypted sessions where the server cannot derive it from plaintext.

This avoids claiming that every malformed known row renders an unsupported placeholder; the actual result correctly depends on the canonical role classification and normalization logic.

Tests and verification

  • Pre-fix RED against the PR base: protocol 5/50 malformed-known cases failed; focused UI 3/5 observable cases failed.
  • Post-fix protocol test: 50/50 passed.
  • Focused UI normalization test: 5/5 passed.
  • Focused CLI role-classification test: 14/14 passed.
  • Direct-session follow-up after Greptile review: protocol 55/55, CLI 16/16, and UI 13/13 focused tests passed.
  • Import follow-up from structured closeout review: RED confirmed the role was dropped; CLI direct-session suite is GREEN at 17/17 focused tests.
  • Protocol TypeScript check passed.
  • git diff --check passed.

The current TypeScript 7 CLI-package check cannot proceed because this older branch's existing apps/cli/tsconfig.json still declares removed option baseUrl (TS5102); that configuration predates and is outside this PR.

Files changed

  • packages/protocol/src/sessionMessages/transcriptRawRecordV1.ts: known-first opaque fallback and naming cleanup.
  • packages/protocol/src/sessionMessages/transcriptRawRecordV1.test.ts: protocol boundary and regression coverage.
  • apps/cli/src/api/session/messageRole/resolveSessionMessageRole.test.ts: malformed-row role contracts.
  • apps/ui/sources/sync/typesRaw/normalize.outputInvalidKnownTypeFallback.test.ts: observable downstream contracts.
  • packages/protocol/src/directSessions/daemonRpcV1.ts, the Claude direct mapper, and the UI direct normalizer: validated role metadata and consistent event filtering for direct transcripts.
  • apps/cli/src/api/directSessions/import/importDirectSessionTranscript.ts: role preservation across import/takeover into managed storage.

The draft RCA document was removed because it was not an appropriate root-level project artifact and its claimed UI outcome was not universally correct.

Residual validation

The reporter's exact privacy-safe payload was unavailable, so the regression is validated with representative shapes already admitted by the Claude transcript producer. A live replay of the original session remains useful release validation, but is not required to establish the parser defect or the cross-layer contracts covered here.

Note

Fail-soft on malformed Claude assistant transcript rows during parsing

  • Replaces the strict RawAgentOutputDataUnknownSchema with a permissive RawAgentOutputDataOpaqueSchema in transcriptRawRecordV1.ts, so malformed known output payloads and unrecognized future types are accepted as opaque data instead of being rejected.
  • Adds messageRole (validated by SessionMessageRoleSchema) as an optional field on DirectTranscriptRawMessageV1Schema, propagating it through the Claude JSONL mapper and normalization pipeline.
  • Malformed assistant rows (e.g. API errors, missing nested role, null content) are classified via resolveClaudeSessionMessageRole and tagged with a role of 'event' or 'agent', enabling downstream filtering in UI sync.
  • Behavioral Change: messages with messageRole: 'event' are now filtered out during normalization rather than passed through as renderable content.

Macroscope summarized 4d47574.

Summary by CodeRabbit

  • Bug Fixes
    • Improved fail-soft parsing/normalization for malformed assistant/output payloads, including missing nested role data and null content.
    • Preserved unknown/future output types while still rejecting invalid core fields.
    • Propagated messageRole through direct transcript ingestion, mapping, and normalization so API-error rows are correctly filtered.
  • Tests
    • Added additional parameterized and schema-focused coverage for edge-case messageRole classification and malformed “known” assistant/output shapes.

RawAgentOutputDataUnknownSchema carried a refine() that rejected any
output `type` present in the known set. Combined with
union([Known, Unknown]), a known-type row whose body did not match its
known variant (e.g. a Claude `assistant` row missing `message`, with
`message.role` missing, or with `content: null`) failed BOTH branches,
so the whole record failed validation and the UI rendered
"[Unparsed agent message]".

Remove the refine so malformed known-type rows fall through to the
catch-all. union([Known, Unknown]) tries Known first, so well-formed
rows still get their rich Known variant (no behavior change for the
happy path). The branded .transform() is preserved so TS narrowing on
`data.type` keeps working.

This realizes the stated intent of commit 4dd41fc ("fail-soft
transcript normalization"), whose Unknown catch-all was meant to absorb
malformed records but was blocked by the collide-refine.

Add regression tests for the three real-world failing shapes plus a
no-regression test for well-formed rows. unparsed_message_error.md
documents the full root-cause analysis.

All 43 pre-existing protocol tests + the 4 new tests pass.
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The transcript schema now preserves malformed assistant output through an opaque branch. Claude direct-session mapping attaches message-role metadata, and UI normalization and transcript import consume it while protocol, mapper, role-resolution, and normalization tests cover the updated behavior.

Changes

Fail-soft output handling

Layer / File(s) Summary
Opaque agent output schema and validation
packages/protocol/src/sessionMessages/transcriptRawRecordV1.ts, packages/protocol/src/sessionMessages/transcriptRawRecordV1.test.ts
Replaces exclusion-based unknown output validation with an opaque branded string branch and verifies malformed records are preserved while invalid shared envelope fields remain rejected.
Direct transcript role metadata contract
packages/protocol/src/directSessions/daemonRpcV1.ts, packages/protocol/src/directSessions/daemonRpcV1.test.ts
Adds optional nullable messageRole validation to direct transcript records and tests valid and invalid values.
Claude role propagation
apps/cli/src/backends/claude/directSessions/mapClaudeJsonlLineToDirectMessages.ts, apps/cli/src/backends/claude/directSessions/mapClaudeJsonlLineToDirectMessages.test.ts, apps/cli/src/api/session/messageRole/resolveSessionMessageRole.test.ts
Resolves and attaches message roles to mapped user and agent/output records, with coverage for API-error, missing-role, and null-content assistant shapes.
Role-aware UI normalization
apps/ui/sources/sync/runtime/directSessions/normalizeDirectTranscriptMessages.ts, apps/ui/sources/sync/typesRaw/normalize.messageRole.test.ts, apps/ui/sources/sync/typesRaw/normalize.outputInvalidKnownTypeFallback.test.ts
Forwards producer role metadata into normalization and tests filtering of event rows, materialization of malformed assistant content, preservation of future output types, and text conversion.
Imported transcript role propagation
apps/cli/src/api/directSessions/import/importDirectSessionTranscript.ts, apps/cli/src/api/directSessions/import/importDirectSessionTranscript.test.ts
Passes messageRole through transcript import and verifies that committed stored messages preserve the metadata.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ClaudeJSONL
  participant ClaudeMapper
  participant DirectTranscriptSchema
  participant TranscriptImporter
  participant UINormalizer
  ClaudeJSONL->>ClaudeMapper: parse assistant or user record
  ClaudeMapper->>ClaudeMapper: resolve messageRole
  ClaudeMapper->>DirectTranscriptSchema: emit raw record with messageRole
  DirectTranscriptSchema->>TranscriptImporter: validate imported transcript
  TranscriptImporter->>TranscriptImporter: commit messageRole
  DirectTranscriptSchema->>UINormalizer: validate and forward direct transcript
  UINormalizer->>UINormalizer: filter event rows and normalize output content
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately summarizes the main protocol change.
Description check ✅ Passed The description covers the problem, approach, observable behavior, tests, and follow-up notes, though it does not use the exact template headings.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@leeroybrun
leeroybrun marked this pull request as ready for review July 11, 2026 14:57
@leeroybrun

Copy link
Copy Markdown
Collaborator

@coderabbitai @greptile-apps please review the latest changes. The PR now includes cross-layer protocol, CLI role-classification, and UI normalization regression coverage, plus cleanup of the opaque fallback naming and stale RCA document.

@leeroybrun

Copy link
Copy Markdown
Collaborator

@coderabbitai review

@leeroybrun

Copy link
Copy Markdown
Collaborator

@greptile-apps review

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
gh pr view 196 --repo happier-dev/happier --json title,body,files,additions,deletions,commits -q '.'
echo "---DIFF---"
gh pr diff 196 --repo happier-dev/happier

Length of output: 19434


🏁 Script executed:

#!/bin/bash
set -euo pipefail
fd -i "unparsed_message_error" || true
git -C . log --oneline -3 2>/dev/null || true
gh pr diff 196 --repo happier-dev/happier --name-status

Length of output: 1131


🏁 Script executed:

#!/bin/bash
set -euo pipefail
fd -i "unparsed" .
echo "---"
gh pr diff 196 --repo happier-dev/happier --name-only

Length of output: 537


Confirmed the RCA doc is gone from the repo, and the diff matches the PR description exactly.

@leeroybrun Thanks — I pulled the latest diff and reviewed all three commits together (db3d27bc, 9ebf72d8, e6b0f207).

packages/protocol/src/sessionMessages/transcriptRawRecordV1.ts

  • The removal of KNOWN_OUTPUT_DATA_TYPES and the .refine() collision check is exactly the root cause fix: previously a malformed known-type row failed both the Known and Unknown branches of the union, causing the whole record to fail validation.
  • RawAgentOutputDataSchema = z.union([RawAgentOutputDataKnownSchema, RawAgentOutputDataOpaqueSchema]) correctly tries the strict Known branch first, so well-formed rows are unaffected (no happy-path regression) while malformed/future rows fall through to the new Opaque branch.
  • The rename from UnknownOpaque (and UnknownOutputDataTypeOpaqueOutputDataType) is a sensible clarity improvement given the branch now also absorbs malformed known types, not just genuinely-unknown ones. The inline comment explaining this is helpful.

Test coverage

  • transcriptRawRecordV1.test.ts: the new it.each block covers 5 representative malformed-known shapes plus a dedicated test confirming the shared envelope (non-string uuid) is still rejected, plus a no-regression test for a well-formed assistant row producing the rich known variant. This is a solid boundary test set matching the PR's "known-first, opaque-fallback, envelope-still-strict" contract.
  • resolveSessionMessageRole.test.ts: the 3 new cases (message-less API error → event, missing nested role → agent, null content → event) validate that the CLI's role classifier degrades sensibly on these malformed shapes.
  • normalize.outputInvalidKnownTypeFallback.test.ts: rewritten with a wrapOutput helper and now asserts the actual observable behavior per role (filtered as null for event, materialized text for agent, visible for unknown/future types), instead of the previous single generic "accepts as unknown" assertion. This is a meaningfully stronger and more honest test than before.

Housekeeping

  • Confirmed unparsed_message_error.md is no longer present in the repo — the stale RCA doc has indeed been removed as described.

Everything in the diff lines up with the PR description's claims, the union ordering guarantees no happy-path regression, and the cross-layer tests (protocol → CLI role classification → UI normalization) give good confidence in the fail-soft behavior at each boundary. I don't see any outstanding concerns.

[approve]

@greptile-apps

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes Claude transcript parsing tolerate incomplete provider payloads while keeping the shared envelope strict. The main changes are:

  • Adds a known-first opaque fallback for malformed and future output rows.
  • Carries validated message-role metadata through direct-session transport and UI normalization.
  • Preserves message roles when importing direct sessions into managed storage.
  • Adds protocol, CLI, and UI coverage for malformed rows and event filtering.

Confidence Score: 5/5

This looks safe to merge.

  • The direct transcript path now carries the role needed for consistent event filtering.
  • Import and takeover preserve the role outside both plain and encrypted content envelopes.
  • The protocol changes remain compatible with transcript producers that omit the optional field.
  • No blocking issue was found in the updated code.

Important Files Changed

Filename Overview
packages/protocol/src/sessionMessages/transcriptRawRecordV1.ts Adds an opaque fallback after strict known-row parsing while retaining shared-envelope validation.
packages/protocol/src/directSessions/daemonRpcV1.ts Adds optional validated message-role metadata to direct transcript items.
apps/cli/src/backends/claude/directSessions/mapClaudeJsonlLineToDirectMessages.ts Classifies parsed Claude rows and attaches the canonical role to direct transcript messages.
apps/ui/sources/sync/runtime/directSessions/normalizeDirectTranscriptMessages.ts Forwards direct transcript role metadata into canonical message normalization.
apps/cli/src/api/directSessions/import/importDirectSessionTranscript.ts Preserves direct transcript roles when committing imported messages to managed storage.

Reviews (5): Last reviewed commit: "fix(transcript): retain roles during dir..." | Re-trigger Greptile

@greptile-apps

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes Claude transcript parsing fail softly for malformed known output rows. The main changes are:

  • Adds an opaque fallback after strict known-row parsing.
  • Keeps shared output-envelope fields validated.
  • Adds protocol, CLI role, and UI normalization tests.

Confidence Score: 5/5

The changed flow looks mergeable after aligning direct-session handling with synced-session event filtering.

  • Strict known rows and shared envelope validation remain intact.
  • Synced sessions classify and filter the new malformed-row cases as intended.
  • Direct sessions lack the role metadata needed to filter the same event rows.

packages/protocol/src/sessionMessages/transcriptRawRecordV1.ts and the direct-session normalization path

Important Files Changed

Filename Overview
packages/protocol/src/sessionMessages/transcriptRawRecordV1.ts Allows malformed known output bodies through the opaque branch while retaining shared-field validation.
packages/protocol/src/sessionMessages/transcriptRawRecordV1.test.ts Covers malformed known rows, invalid shared fields, and well-formed assistant rows.
apps/cli/src/api/session/messageRole/resolveSessionMessageRole.test.ts Adds role expectations for malformed assistant payloads.
apps/ui/sources/sync/typesRaw/normalize.outputInvalidKnownTypeFallback.test.ts Covers synced-session filtering and content recovery but not direct-session normalization.

Reviews (2): Last reviewed commit: "refactor(protocol): clarify opaque outpu..." | Re-trigger Greptile

.transform((value) => ({ ...value, type: value.type as OpaqueOutputDataType }));

const RawAgentOutputDataSchema = z.union([RawAgentOutputDataKnownSchema, RawAgentOutputDataUnknownSchema]);
const RawAgentOutputDataSchema = z.union([RawAgentOutputDataKnownSchema, RawAgentOutputDataOpaqueSchema]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Direct Transcripts Expose Event Placeholders

When this fallback admits a message-less or null-content assistant row, direct-session normalization has no messageRole and cannot apply the event filter used by synced sessions. Those rows therefore appear as [Unsupported agent output] instead of being filtered, so the same transcript has different visible output depending on which normalization path loads it.

Context Used: apps/ui/CLAUDE.md (source)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. Direct transcript items did not carry the producer-owned message role, so the UI could not apply the same event filtering as synced transcripts. Fixed in 72466d5 by adding validated optional messageRole metadata to DirectTranscriptRawMessageV1, populating it through the existing Claude role classifier, and passing it into the canonical UI normalizer. RED was reproduced in protocol, CLI direct mapping, and UI direct normalization; focused GREEN results are protocol 55/55, CLI 16/16, and UI 13/13.

@greptile-apps

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes Claude transcript parsing fail softly for incomplete provider payloads. The main changes are:

  • Adds an opaque fallback for malformed known rows and future output types.
  • Keeps shared transcript envelope fields strictly validated.
  • Adds protocol tests for malformed and well-formed output rows.
  • Adds CLI and UI tests for role classification, filtering, and text recovery.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.
  • Strict known rows remain the first parsing branch.
  • The opaque branch still validates shared envelope fields.
  • Downstream guards handle the newly accepted malformed rows without unsafe field access.

Important Files Changed

Filename Overview
packages/protocol/src/sessionMessages/transcriptRawRecordV1.ts Allows malformed known output bodies to use the opaque fallback while retaining strict shared-field validation.
packages/protocol/src/sessionMessages/transcriptRawRecordV1.test.ts Adds protocol tests for malformed known rows, invalid envelope fields, and valid assistant rows.
apps/cli/src/api/session/messageRole/resolveSessionMessageRole.test.ts Adds role-classification tests for incomplete Claude assistant rows.
apps/ui/sources/sync/typesRaw/normalize.outputInvalidKnownTypeFallback.test.ts Adds downstream tests for filtering, recoverable text, future output types, and string content.

Reviews (3): Last reviewed commit: "refactor(protocol): clarify opaque outpu..." | Re-trigger Greptile

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 11, 2026
@leeroybrun

Copy link
Copy Markdown
Collaborator

@greptile-apps review

@leeroybrun

Copy link
Copy Markdown
Collaborator

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 11, 2026
@leeroybrun

Copy link
Copy Markdown
Collaborator

@coderabbitai review

@leeroybrun

Copy link
Copy Markdown
Collaborator

@greptile-apps review

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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.

2 participants