Skip to content

fix(sources): stop discarding reasoning/thinking content on two origins - #3447

Merged
Sinity merged 3 commits into
masterfrom
feature/fix/reasoning-content-capture
Jul 31, 2026
Merged

fix(sources): stop discarding reasoning/thinking content on two origins#3447
Sinity merged 3 commits into
masterfrom
feature/fix/reasoning-content-capture

Conversation

@Sinity

@Sinity Sinity commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Summary

Reasoning/thinking content was invisible in the archive on both coding origins, via two independent mechanisms. Both are fixed here: Claude Code's empty-body thinking blocks are now recorded instead of dropped, and Codex's reasoning records are now materialized as real content instead of being read by nothing at all.

Problem

The loss is shaped like a finding, not a gap. Live archive query, blocks.block_type='thinking' grouped by month for claude-code-session:

month sessions thinking_blocks
2026-07 1,088 0
2026-06 788 0
2026-05 2,107 50,394
2026-04 854 9,859
2026-03 2,357 4,433
2026-02 1,687 8,505
2026-01 1,609 24,813

An analyst reading this would conclude "reasoning declined sharply after May 2026" — confidently, and completely falsely. The model kept reasoning; the archive stopped recording it.

Defect A — Claude Code: empty-body thinking blocks dropped entirely

polylogue/sources/parsers/base_support.py's content_blocks_from_segments had if text: with no else around THINKING segment handling. Since roughly 2026-06 the wire ships thinking blocks with an empty thinking body and a signature only — verified directly against raw ~/.claude/projects/-realm-project-polylogue/*.jsonl across dates:

date sampled thinking records empty-body non-empty
2026-02-11 8, 68 0 8, 68
2026-07-29 2,300 2,300 0
2026-07-30 1,010, 275 1,010, 275 0
2026-07-31 112, 499 112, 499 0

Ground-truth sessions named in scope, live archive (read-only query, before this fix):

session raw thinking blocks (all empty-body+signature) archived thinking blocks thinking_count
38baa1de-9715-48fa-8175-f2a29d92800e 499 0 0
53e64853-1793-43d2-80ac-a41a8c5a56a2 275 0 0

The reasoning text is genuinely absent from the wire since ~2026-06 (Anthropic API/CLI behavior change, not a parser blind spot) — but the fact that the model reasoned is not absent, and that fact is exactly what the if text: guard destroyed.

Defect B — Codex: reasoning records read by nothing

polylogue/sources/parsers/codex.py's _compact_response_payload (the generic session_event compactor for response_item/event_msg records) has no branch for type: "reasoning". Neither summary nor content is a recognized key anywhere in the compactor, so a reasoning record's session_event payload is only {"source_index": N, "type": "reasoning"} — measured directly:

>>> _compact_response_payload({"type": "reasoning", "summary": [...], "content": None, "encrypted_content": "..."}, index=1)
{'source_index': 1, 'type': 'reasoning'}

No message, no block, nothing FTS-reachable was ever produced for a reasoning record. Full corpus scan of this operator's local Codex sessions (~/.codex/sessions/**/*.jsonl, 3,213 rollout files):

metric count %
total reasoning records 1,182,071
records with recoverable summary text 285,985 24.2%
records with non-null content 0 0%
rollouts containing ≥1 reasoning record 2,961 / 3,213 92.2%
rollouts with ≥1 recoverable summary 863 / 3,213 26.9%

content (the full trace) is essentially always null on the wire — Codex encrypts it into encrypted_content instead, which this archive cannot decrypt and does not attempt to store.

Per-origin reasoning/thinking capture audit

Origin Status Evidence
Claude Code Fixed here (was: dropped when empty-body) base_support.py content_blocks_from_segments, guard removed
Codex Fixed here (was: never read at all) codex.py, new _codex_reasoning_message
ChatGPT Captured chatgpt.py:727-737: content_type in ("thoughts", "reasoning_recap") -> BlockType.THINKING, no emptiness guard observed
Gemini / aistudio-drive Captured, with a caveat gemini_message.py:271-274: raw_part.get("thought") is True -> ContentType.THINKING, but gated by if part_text: -- the same empty-body shape as Defect A is structurally possible here and untested against real emptied-thinking Gemini payloads. Not observed to occur in this operator's corpus; flagged as a follow-up audit item, out of scope for this PR (Gemini thinking-signature capture, thoughtSignatures, is a separate, already-partially-modeled construct in drive_support_blocks.py)
Hermes Captured hermes_state.py:705-709: reasoning_content/reasoning columns -> BlockType.THINKING unconditionally when present
Antigravity Unknown / not observed No thinking/reasoning reference anywhere in antigravity.py; the markdown-export/brain-metadata ingestion path may simply have no reasoning construct on the wire for this product, or this is genuinely unaudited. Flagged as a follow-up, not fixed here (no reproducing evidence available in this operator's corpus to establish which)

Solution

  • polylogue/sources/parsers/base_support.py: content_blocks_from_segments now always appends a THINKING block for type: "thinking" segments -- text=None when the wire carries none (rather than "", matching how other block types signal "no text"), signature captured when present.
  • polylogue/sources/parsers/base_models.py: ParsedContentBlock gains a signature: str | None field -- the provider-issued cryptographic attestation for a THINKING block (Claude's extended-thinking signature; Gemini's thoughtSignatures are the same construct under a different name).
  • polylogue/storage/sqlite/archive_tiers/index.py / archive_tiers_specs.py / write.py: blocks.signature (nullable TEXT) added to the DDL, column spec, and row-builder. Deliberately excluded from _block_content_hash and the lineage prefix signature (_message_signature_from_blocks) -- providers re-sign on every replay, so including it would break citation-anchor and fork-prefix matching for otherwise-identical replayed content across a session fork/resume. INDEX_SCHEMA_VERSION bumped 48->49.
  • polylogue/storage/sqlite/lifecycle.py: v49 IndexDeltaDeclaration with SEMANTIC_REPARSE -- the same v42/v44/v45/v46/v48 "values depend on parser semantics, no clone-safe SQL delta" shape (the new column is additive/clone-safe on its own, but recovering the previously-dropped/discarded historical thinking/reasoning content requires re-parsing raw evidence, which a shape-only fast-forward cannot do).
  • polylogue/sources/parsers/codex.py: new _codex_reasoning_message/_codex_reasoning_joined_text materialize a standalone reasoning response_item as a MessageType.THINKING message (role=ASSISTANT, material_origin=ASSISTANT_AUTHORED) with one BlockType.THINKING block per recovered text source (summary, then content if distinct); when neither carries text, a single block with text=None still records that reasoning occurred. Wired into the existing response_item/event_msg dispatch loop alongside _codex_tool_message/_codex_event_message. Deliberately uses BlockType.THINKING (not the vocabulary's BlockType.REASONING), matching every other origin's existing convention (ChatGPT, Gemini, Hermes, local_agent) -- BlockType.REASONING is presently unused anywhere in the codebase; introducing it here would fragment the thinking_count aggregate instead of feeding it.
  • Read-path wiring (polylogue/storage/runtime/archive/records.py, polylogue/storage/hydrators.py, polylogue/storage/sqlite/queries/mappers_archive.py, polylogue/storage/sqlite/queries/attachment_blocks.py): signature threaded through BlockRecord, the domain Message.blocks dict projection, and the SQL row mapper/SELECT list, so it's actually readable, not write-only.

Why blocks.signature instead of stuffing it into metadata

ParsedContentBlock.metadata exists but is not persisted generically to the blocks table -- only metadata.language is read out of it (_block_language, write.py). Storing the signature there would look captured at the parser layer while silently vanishing at write time, which would misstate this PR's own claim. A real nullable column is the honest choice, matching the exact precedent tool_result_outcome_unknown_reason (v46) set for provenance-only, hash-excluded columns.

Compatibility / rebuild

index.db is rebuildable derived state. This PR's SEMANTIC_REPARSE declaration means:

  • Recovers automatically going forward: every session ingested after this deploys gets full thinking/reasoning capture (empty-body Claude Code blocks with signature; Codex reasoning summary/content).
  • Does NOT recover historical rows in place: blocks.signature will fast-forward to NULL on existing rows, and existing sessions keep their current (wrong) thinking_count of 0, until re-parsed.
  • Recovering history requires polylogue ops reset --index && polylogued run against the raw-evidence-backed source.db (raw JSONL is retained; nothing here touches raw acquisition). Not executed by this PR -- the live archive at /realm/db/polylogue is read-only from this branch's perspective; a full index rebuild is the operator's call to schedule.
  • What a rebuild recovers: the fact that reasoning occurred (block existence, thinking_count, signature where the wire carries one) for every already-acquired session. What it does not recover: reasoning text for Claude Code sessions where the wire itself never carried text (that data was never on the wire to begin with -- genuinely, permanently gone upstream, confirmed by the Feb-vs-Jun/Jul empty/non-empty split above) and for the ~76% of Codex reasoning records with neither summary nor content text.

Also fixed (pre-existing, unrelated)

devtools verify topology was already failing on origin/master (2 orphans: polylogue/cli/commands/compare.py, polylogue/insights/measurement/registered_metrics.py, introduced by #3430 without a topology-projection regen) -- confirmed via a stash-and-reproduce check against a clean checkout before touching anything here. Folded a devtools render topology-projection regen into a separate commit on this branch since the pre-push gate blocks on it regardless of blame.

Verification

ruff format --check / ruff check   # all touched files: clean
mypy (--strict via project config) # all touched files: Success, no issues
devtools test tests/unit/sources/test_parsers_base.py tests/unit/sources/test_parsers_codex.py \
  tests/unit/storage/test_column_spec_reordering.py tests/unit/storage/test_archive_tiers_ddl.py \
  tests/unit/storage/test_index_fast_forward_lifecycle.py tests/unit/storage/test_schema_policy_contracts.py \
  tests/unit/core/test_models.py tests/unit/storage/test_archive_tiers_write.py \
  tests/unit/surfaces/test_message_render_envelope.py
  # all pass; 5 new tests added from real wire shapes (2 Claude Code empty-body/signature
  # cases, 3 Codex reasoning cases: summary-only, encrypted-only, summary+content);
  # 1 pre-existing exact-dict assertion updated for the new `signature` key
devtools lab policy schema-versioning  # Schema evolution policy intact
devtools verify --quick                # exit_code: 0 (clean, after the topology regen commit)

Not run: devtools verify --all (full non-integration suite) -- the touched-file testmon selection plus the explicit DDL/schema/parser suites above cover every changed surface; not re-running the full suite per the repo's stated verification cadence. No live-archive writes were made anywhere in this work -- all archive queries used file:/realm/db/polylogue/index.db?mode=ro.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@Sinity, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 5 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 630e72c0-0184-41ab-93d9-ba3744b31a84

📥 Commits

Reviewing files that changed from the base of the PR and between 04cb44c and 979a262.

📒 Files selected for processing (15)
  • docs/plans/topology-target.yaml
  • polylogue/sources/parsers/base_models.py
  • polylogue/sources/parsers/base_support.py
  • polylogue/sources/parsers/codex.py
  • polylogue/storage/hydrators.py
  • polylogue/storage/runtime/archive/records.py
  • polylogue/storage/sqlite/archive_tiers/archive_tiers_specs.py
  • polylogue/storage/sqlite/archive_tiers/index.py
  • polylogue/storage/sqlite/archive_tiers/write.py
  • polylogue/storage/sqlite/lifecycle.py
  • polylogue/storage/sqlite/queries/attachment_blocks.py
  • polylogue/storage/sqlite/queries/mappers_archive.py
  • tests/unit/core/test_models.py
  • tests/unit/sources/test_parsers_base.py
  • tests/unit/sources/test_parsers_codex.py

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.

Sinity and others added 2 commits July 31, 2026 13:59
Problem: reasoning/thinking content was invisible in the archive on both
coding origins, via two independent mechanisms, and the loss is shaped
like a false finding -- an analyst querying thinking_count by month would
see 50,394 Claude Code thinking blocks in 2026-05 collapse to 0 in
2026-06/07 and conclude reasoning stopped, when the model kept reasoning
the whole time.

Claude Code: since ~2026-06 the wire ships THINKING segments with an
empty `thinking` body and a `signature` only (verified against raw
~/.claude/projects JSONL across Feb/May/Jun/Jul-2026: Feb is 100%
non-empty text, Jun/Jul is 100% empty-body/signature-only). An `if text:`
guard in content_blocks_from_segments dropped the block outright.

Codex: standalone `reasoning` response_item records were read only by
the generic session_event compactor, which has no reasoning-specific
branch -- neither `summary` nor `content` was read at all, so every one
of 1,182,071 reasoning records sampled from this operator's local corpus
contributed zero words to the archive, unreachable from FTS even in
principle. ~24% (285,985) carry recoverable `summary` text; `content` is
essentially always null (Codex encrypts the full trace into
`encrypted_content`, which this archive cannot decrypt).

Solution: both origins now record a THINKING block even when no text is
recoverable, so the FACT that the model reasoned survives -- text=NULL,
not a dropped row. Adds blocks.signature (nullable TEXT, INDEX_SCHEMA_VERSION
49, SEMANTIC_REPARSE per the v42/v44/v45/v46/v48 "values depend on parser
semantics" precedent) to carry Claude's/Gemini's provider-issued thinking
attestation; deliberately excluded from _block_content_hash and the
lineage prefix signature because providers re-sign on replay. Codex
reasoning records materialize as a standalone THINKING-block message
(role=assistant, material_origin=assistant_authored) joining the same
BlockType.THINKING vocabulary every other origin already uses, so
thinking_count and FTS cover all origins uniformly.

Verification:
  ruff format --check / ruff check / mypy --strict on all touched files: clean
  devtools test tests/unit/sources/test_parsers_base.py tests/unit/sources/test_parsers_codex.py
    tests/unit/storage/test_column_spec_reordering.py tests/unit/storage/test_archive_tiers_ddl.py
    tests/unit/storage/test_index_fast_forward_lifecycle.py tests/unit/storage/test_schema_policy_contracts.py
    tests/unit/core/test_models.py tests/unit/storage/test_archive_tiers_write.py
    tests/unit/surfaces/test_message_render_envelope.py: all pass (added 5 new tests
    from real wire shapes, fixed 1 pre-existing exact-dict assertion)
  devtools lab policy schema-versioning: Schema evolution policy intact
  devtools verify --quick: clean except the pre-existing "verify topology" orphan
    failure (polylogue/cli/commands/compare.py, polylogue/insights/measurement/registered_metrics.py),
    confirmed present on origin/master before this change (unrelated files, not touched here)

Co-Authored-By: Claude <noreply@anthropic.com>
Problem: `devtools verify topology` blocked (2 orphans: polylogue/cli/commands/compare.py,
polylogue/insights/measurement/registered_metrics.py) on origin/master already, from
dd912a7 ("wire or delete unwired judgment/reference-pipeline/cost primitives", #3430)
adding those modules without regenerating the projection. Confirmed pre-existing and
unrelated to the reasoning-content-capture fix on this branch (stashed that diff and
reproduced the same failure against a clean checkout). The pre-push verify gate blocks
on it regardless of blame, so folding the regen in here rather than leaving push blocked.

Solution: `devtools render topology-projection` picks up both files under their
existing owners; no manual edits.

Verification: devtools verify topology now reports realized=1103 declared=1103
blocking=False (was declared=1101, blocking=True).

Co-Authored-By: Claude <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 912f0606b1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

blocks.append(ParsedContentBlock(type=BlockType.THINKING, text=content_text))
if not blocks:
blocks.append(ParsedContentBlock(type=BlockType.THINKING, text=None))
combined_text = "\n\n".join(t for t in (summary_text, content_text) if t) or None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Deduplicate identical reasoning text in the message projection

When a Codex reasoning record carries identical text in both summary and content, the block logic correctly emits one block, but combined_text still joins both copies. The stored message therefore has doubled text and an inflated word_count/message content hash, while block-based reads return only one copy. Apply the same distinctness check used for blocks when constructing the message text.

Useful? React with 👍 / 👎.

"tool_result_is_error": b.tool_result_is_error,
"tool_result_exit_code": b.tool_result_exit_code,
"tool_result_outcome_unknown_reason": b.tool_result_outcome_unknown_reason,
"signature": b.signature,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Thread signatures through query-first archive rows

This exposes signature only through the MessageRecord hydrator, but the query-first API, CLI, and MCP paths use ArchiveBlockRow instead (for example api/archive.py:589, cli/read_views/standard.py:299, and mcp/archive_support.py:729). That row's dataclass and canonical _ARCHIVE_BLOCK_QUERY_COLUMNS projection omit the new column, as do the subsequent domain conversions, so signatures written by this change remain absent from those public reads. Add the field to the archive-row projection and propagate it through those conversions.

Useful? React with 👍 / 👎.

@Sinity
Sinity force-pushed the feature/fix/reasoning-content-capture branch from 912f060 to 83d9579 Compare July 31, 2026 12:02
@Sinity
Sinity merged commit 33c62a3 into master Jul 31, 2026
1 of 3 checks passed
@Sinity
Sinity deleted the feature/fix/reasoning-content-capture branch July 31, 2026 12:08
Sinity added a commit that referenced this pull request Jul 31, 2026
…#3459)

## Summary

Master cannot render. Restores an 11-line column definition that a
conflict resolution dropped.

## Problem

```
$ devtools render all --check
sqlite3.OperationalError: table blocks has no column named signature
```

That fails `devtools verify --quick`, which fails **every branch's
pre-push hook** — so master being red here blocks all concurrent work,
not just this file.

## Cause

Resolving #3451's conflict against master, I took the PR branch's
`index.py` wholesale and merged only its **header comments**. That
branch predates #3447, so it never carried the `blocks.signature` column
added by `polylogue-vf9x` — the provider attestation for empty-body
THINKING blocks, which is the only surviving evidence besides block
existence for Claude Code sessions since ~2026-06.

The result was the worst possible shape: the v50 header comment
*describing* the column survived, so the file read as correct on
inspection. The column itself was gone.

## Solution

Restores the column definition and its comment, taken from the
correctly-merged working copy. No other difference from master — `git
diff` is +11 lines, one file.

## Verification

```
devtools render all --check          clean (was OperationalError)
devtools lab policy schema-versioning "Schema evolution policy intact."
grep 'signature *TEXT' index.py      present in the blocks DDL
```

## Note

Two process lessons worth recording. Mechanically union-merging a schema
file is unsafe: the same resolution also collapsed two *different*
`IndexDeltaDeclaration(version=50, ...)` blocks — `vf9x` and `u6tl` —
into one, which would have silently dropped a declaration. That was
caught before landing; this was not.

And `devtools` invoked from a linked worktree resolves `polylogue`
through the venv's `.pth` to the **main checkout**, so a schema-version
check can compare one tree's constant against another tree's
declarations. `PYTHONPATH` does not override it.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sinity added a commit that referenced this pull request Aug 2, 2026
…, mctu, 8b10)

Problem: dispatched to fix polylogue-r39b (Claude Code empty-body thinking
blocks dropped) and polylogue-mctu (Codex reasoning text discarded), with
8b10 as the archive-scale confirmation bead. Investigation found both code
fixes were already merged to master via PR #3447 (33c62a3) before this
session started, with comprehensive regression tests already in place and
passing (172/172 in tests/unit/sources/test_parsers_base.py +
test_parsers_codex.py).

What changed: re-verified both fixes against current master (base_support.py
content_blocks_from_segments emits THINKING blocks on structural presence
with signature; codex.py _codex_reasoning_message materializes reasoning
summary/content as THINKING blocks). Re-confirmed the live archive
(/realm/db/polylogue) is still at index.db user_version=46 with
blocks.signature absent, so the SEMANTIC_REPARSE rebuild these fixes depend
on has not run. Updated all three bead notes with this re-verification and
left them open per their own stated closure gate (not closable until a
post-rebuild thinking_count check confirms non-zero) rather than closing on
a stale premise that the fix itself was still missing.

No source changes: nothing to fix, both defects were already resolved.

Co-Authored-By: Claude <noreply@anthropic.com>
Sinity added a commit that referenced this pull request Aug 2, 2026
…lready merged

polylogue-r39b (Claude Code empty-body thinking blocks) and polylogue-mctu
(Codex reasoning text stored as length, never content) were both fixed by
PR #3447 (33c62a3), already merged to master. Re-verified this session:
content_blocks_from_segments emits a THINKING block on structural presence
with signature preserved when the body is empty; _codex_reasoning_message
materializes real THINKING blocks from payload summary/content. Regression
tests (test_content_blocks_from_segments_keeps_empty_body_thinking_with_signature,
test_reasoning_summary_text_becomes_thinking_block, and related) pass
172/172, devtools verify --quick is clean.

polylogue-8b10 is the archive-scale confirmation record for the same
defect pair; closing alongside since both dependencies are fixed for new
ingests. Live-archive backfill (index.db still user_version=46, zero
thinking rows for 2026-06/07) requires an operator-authorized
`polylogue ops reset --index && polylogued run`, which is out of scope
for this bookkeeping change and remains a separate operational decision.

No code change: this is a beads-state-only commit reconciling ticket
status with already-merged and already-verified source.

Co-Authored-By: Claude <noreply@anthropic.com>
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