codeburn/src/content-utils.ts
Resham Joshi e8009c4559
Some checks are pending
CI / semgrep (push) Waiting to run
fix(parser): tolerate string message content; isolate per-file parse failures (#441) (#450)
Some agents (Pi, and others for injected turns) write a message's `content`
as a string instead of an array of blocks. Parsers did `(content ?? []).filter`,
which throws on a string — and because the daily-cache backfill swallowed parse
errors, one bad session silently wiped the entire trend/history.

- Add normalizeContentBlocks(): string -> one text block, array -> as-is (by
  reference; drops null/undefined elements so the same crash can't happen one
  level down), else -> []. Applied across pi/codex/droid/cursor-agent and the
  Claude path in parser.ts.
- Isolate per-file parse failures in parseProviderSources: skip the offending
  file (warn once per provider) instead of re-throwing and aborting the whole
  backfill. The stale cache entry is already cleared, so the file is excluded.
- Surface backfill failures in hydrateCache via stderr instead of silently
  returning an empty cache.

Likely fixes #425 (previous-day always 0) for the throwing-file cause.
Tests: content-utils unit tests + a Pi string-content regression test.
2026-06-06 04:01:12 +02:00

26 lines
1.5 KiB
TypeScript

/// Normalize a message's `content` into an array of content blocks.
///
/// Most agent session formats write `content` as an array of typed blocks
/// (`{ type: 'text' | 'tool_use' | ... }`), and the parsers filter over that
/// array. But some agents (Pi, and others for programmatically injected turns)
/// legitimately write `content` as a plain **string**. A raw string reaching
/// `.filter`/`.some` throws a TypeError mid-parse — and because the 365-day
/// daily-cache backfill swallows parse errors, that single bad record silently
/// wipes the entire trend/history (issue #441).
///
/// This coerces defensively: arrays pass through, a string becomes one text
/// block, and anything else (null/undefined/number/object) becomes empty.
export function normalizeContentBlocks<T extends { type?: string; text?: string }>(
content: T[] | string | null | undefined,
): T[] {
if (Array.isArray(content)) {
// A clean array (the overwhelming common case) is returned by reference — no
// copy. Only when an element is a non-object (null/undefined/primitive) do we
// filter, since the call sites read `.type` on each element and a null would
// throw — the same crash class this helper exists to prevent, one level down.
const isBlock = (b: T): boolean => b != null && typeof b === 'object'
return content.every(isBlock) ? content : content.filter(isBlock)
}
if (typeof content === 'string') return [{ type: 'text', text: content } as T]
return []
}