Skip to content

fix(directors-notes): never render the raw structured output as the report - #1331

Merged
pedramamini merged 1 commit into
rcfrom
fix/directors-notes-plain-mode
Jul 31, 2026
Merged

fix(directors-notes): never render the raw structured output as the report#1331
pedramamini merged 1 commit into
rcfrom
fix/directors-notes-plain-mode

Conversation

@pedramamini

@pedramamini pedramamini commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

What broke

Director's Notes Plain Mode showed a wall of raw JSON instead of a report:

{"version":1,"sections":[{"kind":"accomplishments","title":"Accomplishments","items":[{"text":"Ran the Signal, iMessage, ...

The synopsis agent emits a structured JSON narrative. Plain Mode renders narrative ? narrativeToMarkdown(narrative) : synopsis, so whenever the strict parser rejected a run there was no narrative and the raw string went straight to the markdown renderer. Rich Mode already failed loudly for that same input (NarrativeParseError); Plain Mode silently dumped the object.

Reproduced against the live 0.18.5-RC app: a fresh 10-day run (19 agents, 15,003 entries, 2m52s) parsed clean, so the failure is per-run, not a broken pipeline. That is exactly why the raw fallback was invisible until it wasn't.

What this changes

Plain Mode never renders the raw structured output. It shows the narrative as prose, or the shared parse-failure banner with the raw text behind its "View raw output" disclosure.

A rejected run no longer costs the whole report. recoverDirectorNotesNarrative() runs only after the strict parser fails and handles the failures that actually happen in the field:

  • response cut off mid-stream: cut back to the last complete item, close what is still open
  • raw line breaks inside a bullet (invalid JSON the prompt forbids, models do anyway)
  • one malformed bullet or section: dropped individually, not the document

Recovery is never silent. It returns a reason ("the response was cut off before it finished") and both modes render NarrativeParseError in a softer partial-recovery state above the salvaged narrative, so a partial report never reads as a complete one. The strict parser's contract is unchanged.

The CLI's -f markdown / -f text output takes the same salvage with the reason noted inline. -f json still returns the raw synopsis untouched.

Notes

  • narrativeRecovery is threaded through the IPC result, preload type, and global.d.ts.
  • applyNarrative() collapses the three places (fresh run, in-flight attach, cache restore) that had to apply the narrative fields by hand.
  • Docs: reading-modes section in docs/director-notes.md, plus a SHARED-UTILS.md entry so nobody hand-rolls JSON repair at a call site.

Testing

  • directorNotesNarrative.test.ts: 46 pass, including truncation at two cut points, raw line breaks, dropped items/sections, title fallback, refusal on non-narrative input, never-throws.
  • All Director's Notes component tests: 128 pass, with new coverage for both Plain Mode failure surfaces (banner instead of JSON, raw reachable, salvaged prose alongside the banner, Copy exporting prose).
  • npm run lint and npm run lint:eslint clean; full suite green via the pre-push hook.

Summary by CodeRabbit

  • New Features

    • Added best-effort recovery for malformed Director’s Notes AI output.
    • Displays recovered readable content with a recovery warning when possible.
    • Shows a parse-failure alert when recovery is unsuccessful, with raw output available on demand.
    • Ensures Plain and Rich modes avoid displaying raw structured output as the final report.
    • Copy actions now export recovered readable prose when available.
  • Documentation

    • Documented parsing, recovery, rendering, and failure-state behavior.

…eport

Plain Mode fed the raw synopsis string straight to the markdown renderer
whenever there was no parsed narrative, so a run whose structured output
failed to parse showed a wall of raw JSON where the report should be.
Rich Mode already failed loudly for the same input; Plain Mode did not.

A synopsis run costs minutes of agent time, so a rejected response should
not cost the whole report either. Add a salvage path that recovers what
can be read and always says what it recovered.

- shared/directorNotesNarrative.ts: add recoverDirectorNotesNarrative(),
  called only after the strict parser fails. Repairs a response cut off
  mid-stream (cut back to the last complete item, close what is open) and
  raw control characters inside strings, then validates leniently: a bad
  bullet is dropped, not the document. Returns a human-readable reason.
- director-notes.ts handler: fall back to the salvage and return the
  recovered narrative plus narrativeRecovery; only a total failure returns
  a bare narrativeError.
- AIOverviewTab: Plain Mode renders the narrative as prose or shows the
  parse-failure banner. It no longer dumps the raw output. One
  applyNarrative() helper keeps the three result paths in sync.
- NarrativeParseError: now shared by both reading modes, with a softer
  partial-recovery state so a salvaged report never reads as complete.
- CLI markdown/text output takes the same salvage, reason noted inline.
- Tests for every recovery case and both Plain Mode failure surfaces.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Director’s Notes now attempts structured-output recovery after strict parsing fails. Recovered narratives and recovery reasons flow through CLI, IPC, preload, and renderer layers, while Plain and Rich modes show recovery or failure banners instead of raw structured output.

Changes

Director’s Notes narrative recovery

Layer / File(s) Summary
Narrative recovery and validation
src/shared/directorNotesNarrative.ts, src/__tests__/shared/directorNotesNarrative.test.ts
Malformed JSON can be repaired and leniently validated, preserving readable sections and items while reporting dropped content or refusing unrecoverable input.
Synopsis API and CLI integration
src/main/ipc/handlers/director-notes.ts, src/main/preload/directorNotes.ts, src/renderer/global.d.ts, src/cli/commands/director-notes-synopsis.ts
Strict parsing is followed by recovery; responses expose narrativeError and narrativeRecovery, and CLI conversion renders recovered markdown when available.
Renderer recovery and failure states
src/renderer/components/DirectorNotes/*, src/__tests__/renderer/components/DirectorNotes/*, docs/agent-guides/SHARED-UTILS.md, docs/director-notes.md
Cached and generated narrative state carries recovery details, Plain and Rich modes display corresponding banners, and tests and documentation describe recovered, failed, and raw-output disclosure behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AI
  participant SynopsisHandler
  participant NarrativeRecovery
  participant DirectorNotesUI
  AI->>SynopsisHandler: structured synopsis output
  SynopsisHandler->>NarrativeRecovery: recover after strict parse failure
  NarrativeRecovery-->>SynopsisHandler: recovered narrative and reason
  SynopsisHandler-->>DirectorNotesUI: narrative, parse error, recovery reason
  DirectorNotesUI-->>DirectorNotesUI: render prose and recovery/failure banner
Loading

Possibly related PRs

Suggested reviewers: jsydorowicz21

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: Director’s Notes no longer renders raw structured output as the report.
Docstring Coverage ✅ Passed Docstring coverage is 85.71% which is sufficient. The required threshold is 80.00%.
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.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/directors-notes-plain-mode

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.

function validateNarrativeLenient(
parsed: unknown
): { narrative: DirectorNotesNarrative; dropped: number } | null {
if (!isPlainObject(parsed) || !Array.isArray(parsed.sections)) return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Recovery bypasses schema versioning

When the agent returns a narrative with an unsupported or missing version, strict parsing rejects it but recovery accepts it based only on sections, causing an incompatible schema to be rendered as a recovered report.

Suggested change
if (!isPlainObject(parsed) || !Array.isArray(parsed.sections)) return null;
if (!isPlainObject(parsed) || parsed.version !== 1 || !Array.isArray(parsed.sections)) return null;

@greptile-apps

greptile-apps Bot commented Jul 30, 2026

Copy link
Copy Markdown

Greptile Summary

This PR prevents malformed Director's Notes output from being rendered as a report.

  • Adds best-effort narrative recovery for truncated output, control characters, and malformed sections or bullets.
  • Threads recovery metadata through the main-process handler, preload bridge, renderer state, Rich and Plain modes, and CLI formatting.
  • Adds shared parse-failure and partial-recovery presentation plus expanded tests and documentation.

Confidence Score: 4/5

The export fallback and recovery version validation need to be fixed before merging.

Unrecoverable structured output is still exported verbatim through Copy and Save, while recovery also renders narratives whose schema version the strict parser rejects.

Files Needing Attention: src/renderer/components/DirectorNotes/AIOverviewTab.tsx, src/shared/directorNotesNarrative.ts

Important Files Changed

Filename Overview
src/shared/directorNotesNarrative.ts Adds centralized recovery for malformed narratives, but the lenient validator does not enforce the narrative schema version.
src/main/ipc/handlers/director-notes.ts Applies strict parsing followed by recovery and returns recovery metadata through the synopsis result.
src/renderer/components/DirectorNotes/AIOverviewTab.tsx Unifies narrative state across lifecycle paths and fixes visible failure rendering, but unrecoverable output still reaches Copy and Save.
src/renderer/components/DirectorNotes/NarrativeParseError.tsx Adds shared total-failure and partial-recovery banners with raw output disclosure.
src/renderer/components/DirectorNotes/RichOverview.tsx Displays recovery warnings alongside salvaged structured narrative content.
src/cli/commands/director-notes-synopsis.ts Reuses shared recovery for human-readable CLI output and annotates partial reports.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Raw synopsis] --> B{Strict parse}
  B -->|Success| C[Structured narrative]
  B -->|Failure| D{Recovery}
  D -->|Recovered| E[Narrative plus recovery warning]
  D -->|Unrecoverable| F[Parse-failure banner]
  C --> G[Rich cards or Plain markdown]
  E --> G
  F --> H[Raw output disclosure]
  C --> I[Copy and Save]
  E --> I
  F --> J[Raw synopsis currently reaches Copy and Save]
Loading

Comments Outside Diff (1)

  1. src/renderer/components/DirectorNotes/AIOverviewTab.tsx, line 227-229 (link)

    P1 Raw output still reaches exports

    When structured output is unrecoverable, plainContent falls back to the raw synopsis, so Copy and Save export malformed JSON as the report even though both reading modes correctly hide it behind the failure disclosure.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Reviews (1): Last reviewed commit: "fix(directors-notes): never render the r..." | Re-trigger Greptile

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/main/ipc/handlers/director-notes.ts (1)

789-801: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider logging recovered.error when recovery also fails.

The warn log captures narrativeError and recoveryReason (only set on success), but not recovered.error when recovery itself fails - that detail (e.g. "No recoverable narrative content found in the response.") would help diagnose why a response was unsalvageable, purely from logs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/ipc/handlers/director-notes.ts` around lines 789 - 801, Update the
warning log in the recoverDirectorNotesNarrative failure path to include
recovered.error when recovery fails, while preserving the existing
recoveryReason logging for successful recovery. Keep the narrativeFields
behavior unchanged.
src/shared/directorNotesNarrative.ts (1)

250-341: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Consider a maintained JSON-repair library instead of hand-rolled truncation/escaping repair.

closeTruncatedJsonObject and escapeControlCharsInStrings reimplement two well-known LLM-output repair problems (closing truncated structures, escaping stray control characters in strings). A maintained library such as jsonrepair already solves both: "The tool attempts to fix truncated JSON by adding missing closing brackets and braces." and more broadly "The jsonrepair npm package fixes over 95% of common JSON issues automatically without any configuration." The section-drop mislabeling bug found in this review is exactly the kind of subtle defect that comes from hand-rolling this class of parser. This is a non-blocking suggestion — the current implementation's per-repair reason attribution (truncated vs. escaped) is a genuine product requirement a generic library wouldn't give you out of the box, so any switch would need to preserve that. Worth evaluating for future robustness/maintenance, not something to block this PR on.

Also applies to: 406-461

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/shared/directorNotesNarrative.ts` around lines 250 - 341, Evaluate
replacing the hand-rolled closeTruncatedJsonObject and
escapeControlCharsInStrings helpers with a maintained JSON-repair library such
as jsonrepair, while preserving the existing per-repair reason attribution for
truncated versus escaped input. Apply the same consideration to the
corresponding repair logic around the additionally referenced section, and
retain current behavior if the library cannot expose the required attribution
reliably.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/renderer/components/DirectorNotes/AIOverviewTab.tsx`:
- Around line 592-611: Update the outer synopsis-rendering guard in the AI
overview component so it remains active when synopsis is empty but
narrativeError is set, allowing NarrativeParseError to render instead of a blank
result. Add a regression test covering an empty synopsis with a narrative parse
failure and verifying the error alert is displayed.

In `@src/shared/directorNotesNarrative.ts`:
- Around line 349-392: Split the recovery accounting in validateNarrativeLenient
into separate section and item drop counters, incrementing each at its
corresponding validation failures and returning both counts. Update
recoverDirectorNotesNarrative to describe dropped sections and bullets
accurately, including correct singular/plural wording, and add a regression
assertion for the reason when an invalid section is discarded.

---

Nitpick comments:
In `@src/main/ipc/handlers/director-notes.ts`:
- Around line 789-801: Update the warning log in the
recoverDirectorNotesNarrative failure path to include recovered.error when
recovery fails, while preserving the existing recoveryReason logging for
successful recovery. Keep the narrativeFields behavior unchanged.

In `@src/shared/directorNotesNarrative.ts`:
- Around line 250-341: Evaluate replacing the hand-rolled
closeTruncatedJsonObject and escapeControlCharsInStrings helpers with a
maintained JSON-repair library such as jsonrepair, while preserving the existing
per-repair reason attribution for truncated versus escaped input. Apply the same
consideration to the corresponding repair logic around the additionally
referenced section, and retain current behavior if the library cannot expose the
required attribution reliably.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ab2da5d3-54db-4661-add5-91e60c0b94d2

📥 Commits

Reviewing files that changed from the base of the PR and between ed898c6 and fe2f799.

📒 Files selected for processing (13)
  • docs/agent-guides/SHARED-UTILS.md
  • docs/director-notes.md
  • src/__tests__/renderer/components/DirectorNotes/AIOverviewTab.test.tsx
  • src/__tests__/renderer/components/DirectorNotes/NarrativeParseError.test.tsx
  • src/__tests__/shared/directorNotesNarrative.test.ts
  • src/cli/commands/director-notes-synopsis.ts
  • src/main/ipc/handlers/director-notes.ts
  • src/main/preload/directorNotes.ts
  • src/renderer/components/DirectorNotes/AIOverviewTab.tsx
  • src/renderer/components/DirectorNotes/NarrativeParseError.tsx
  • src/renderer/components/DirectorNotes/RichOverview.tsx
  • src/renderer/global.d.ts
  • src/shared/directorNotesNarrative.ts

Comment on lines +592 to +611
{narrativeError && (
<NarrativeParseError
theme={theme}
error={narrativeError}
rawOutput={synopsis}
recovery={narrativeRecovery}
/>
)}
{/* Prose from the narrative, or the raw string when there is no
narrative to build from and nothing failed (legacy markdown
synopses and the "no history files" message). */}
{(narrative || !narrativeError) && (
<MarkdownRenderer
content={plainContent}
theme={theme}
onCopy={(text) => safeClipboardWrite(text)}
enableBionifyReadingMode={bionifyReadingMode}
chatMath
/>
)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Render empty-response parse failures.

When synopsis === '' and narrativeError is set, the outer synopsis ? guard prevents Line 592 from rendering the alert, leaving the user with a blank result. Include narrativeError in that guard and add an empty-response regression test.

Proposed fix
-				{synopsis ? (
+				{(synopsis || narrativeError) ? (
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/components/DirectorNotes/AIOverviewTab.tsx` around lines 592 -
611, Update the outer synopsis-rendering guard in the AI overview component so
it remains active when synopsis is empty but narrativeError is set, allowing
NarrativeParseError to render instead of a blank result. Add a regression test
covering an empty synopsis with a narrative parse failure and verifying the
error alert is displayed.

Comment on lines +349 to +392
function validateNarrativeLenient(
parsed: unknown
): { narrative: DirectorNotesNarrative; dropped: number } | null {
if (!isPlainObject(parsed) || !Array.isArray(parsed.sections)) return null;

let dropped = 0;
const sections: NarrativeSection[] = [];

for (const rawSection of parsed.sections) {
if (!isPlainObject(rawSection) || typeof rawSection.kind !== 'string') {
dropped++;
continue;
}
if (!VALID_KINDS.has(rawSection.kind)) {
dropped++;
continue;
}
const kind = rawSection.kind as NarrativeSectionKind;

const items: NarrativeItem[] = [];
for (const rawItem of Array.isArray(rawSection.items) ? rawSection.items : []) {
if (!isPlainObject(rawItem) || typeof rawItem.text !== 'string' || !rawItem.text.trim()) {
dropped++;
continue;
}
const item: NarrativeItem = { text: rawItem.text };
// A bad severity/agent only costs that field, never the bullet.
if (typeof rawItem.severity === 'string' && VALID_SEVERITIES.has(rawItem.severity)) {
item.severity = rawItem.severity as NarrativeItemSeverity;
}
if (typeof rawItem.agent === 'string') item.agent = rawItem.agent;
items.push(item);
}

sections.push({
kind,
title: typeof rawSection.title === 'string' ? rawSection.title : DEFAULT_SECTION_TITLES[kind],
items,
});
}

if (sections.length === 0) return null;
return { narrative: { version: 1, sections }, dropped };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Dropped sections are mislabeled as "bullets" in the recovery reason.

validateNarrativeLenient uses a single dropped counter for both malformed/unknown-kind sections (Lines 358-365) and malformed items (Lines 370-373). recoverDirectorNotesNarrative then always reports this combined count as bullets: `${lenient.dropped} ${...'bullet was' : 'bullets were'} malformed and dropped` (Lines 446-450). If a section is dropped (e.g. unknown kind), the user-facing reason will incorrectly say "1 bullet was malformed and dropped" instead of describing a dropped section. This contradicts the function's own contract: "recoverDirectorNotesNarrative is the salvage path... it always explains what it salvaged (never silently partial)". The existing "drops a section with an unknown kind" test (test file Lines 502-515) doesn't assert on reason, so this wasn't caught.

🐛 Proposed fix: split section/item drop counters
 function validateNarrativeLenient(
 	parsed: unknown
-): { narrative: DirectorNotesNarrative; dropped: number } | null {
+): { narrative: DirectorNotesNarrative; droppedSections: number; droppedItems: number } | null {
 	if (!isPlainObject(parsed) || !Array.isArray(parsed.sections)) return null;
 
-	let dropped = 0;
+	let droppedSections = 0;
+	let droppedItems = 0;
 	const sections: NarrativeSection[] = [];
 
 	for (const rawSection of parsed.sections) {
 		if (!isPlainObject(rawSection) || typeof rawSection.kind !== 'string') {
-			dropped++;
+			droppedSections++;
 			continue;
 		}
 		if (!VALID_KINDS.has(rawSection.kind)) {
-			dropped++;
+			droppedSections++;
 			continue;
 		}
 		const kind = rawSection.kind as NarrativeSectionKind;
 
 		const items: NarrativeItem[] = [];
 		for (const rawItem of Array.isArray(rawSection.items) ? rawSection.items : []) {
 			if (!isPlainObject(rawItem) || typeof rawItem.text !== 'string' || !rawItem.text.trim()) {
-				dropped++;
+				droppedItems++;
 				continue;
 			}
 			...
 		}
 		...
 	}
 
 	if (sections.length === 0) return null;
-	return { narrative: { version: 1, sections }, dropped };
+	return { narrative: { version: 1, sections }, droppedSections, droppedItems };
 }
-		if (lenient.dropped > 0) {
-			reasons.push(
-				`${lenient.dropped} ${lenient.dropped === 1 ? 'bullet was' : 'bullets were'} malformed and dropped`
-			);
-		}
+		if (lenient.droppedItems > 0) {
+			reasons.push(
+				`${lenient.droppedItems} ${lenient.droppedItems === 1 ? 'bullet was' : 'bullets were'} malformed and dropped`
+			);
+		}
+		if (lenient.droppedSections > 0) {
+			reasons.push(
+				`${lenient.droppedSections} ${lenient.droppedSections === 1 ? 'section was' : 'sections were'} malformed and dropped`
+			);
+		}

Consider also adding a regression test asserting result.reason when a section (not just an item) is dropped, so this doesn't regress silently.

Also applies to: 406-461

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/shared/directorNotesNarrative.ts` around lines 349 - 392, Split the
recovery accounting in validateNarrativeLenient into separate section and item
drop counters, incrementing each at its corresponding validation failures and
returning both counts. Update recoverDirectorNotesNarrative to describe dropped
sections and bullets accurately, including correct singular/plural wording, and
add a regression assertion for the reason when an invalid section is discarded.

@pedramamini
pedramamini merged commit c1e5d4f into rc Jul 31, 2026
7 checks passed
@pedramamini
pedramamini deleted the fix/directors-notes-plain-mode branch July 31, 2026 16:44
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.

1 participant