From ba33dca41989e455d9f991a37c14567e60b79dcf Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 31 Jul 2026 13:51:21 +0200 Subject: [PATCH 1/2] fix(sources): stop discarding reasoning/thinking content on two origins 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 --- polylogue/sources/parsers/base_models.py | 12 +++ polylogue/sources/parsers/base_support.py | 20 ++++- polylogue/sources/parsers/codex.py | 90 +++++++++++++++++++ polylogue/storage/hydrators.py | 1 + polylogue/storage/runtime/archive/records.py | 5 ++ .../archive_tiers/archive_tiers_specs.py | 6 +- .../storage/sqlite/archive_tiers/index.py | 42 ++++++++- .../storage/sqlite/archive_tiers/write.py | 4 +- polylogue/storage/sqlite/lifecycle.py | 23 +++++ .../sqlite/queries/attachment_blocks.py | 3 +- .../storage/sqlite/queries/mappers_archive.py | 1 + tests/unit/core/test_models.py | 1 + tests/unit/sources/test_parsers_base.py | 35 ++++++++ tests/unit/sources/test_parsers_codex.py | 81 +++++++++++++++++ 14 files changed, 317 insertions(+), 7 deletions(-) diff --git a/polylogue/sources/parsers/base_models.py b/polylogue/sources/parsers/base_models.py index a0c461a581..34a381755e 100644 --- a/polylogue/sources/parsers/base_models.py +++ b/polylogue/sources/parsers/base_models.py @@ -91,6 +91,18 @@ class ParsedContentBlock(BaseModel): tool_input: Mapping[str, object] | None = None media_type: str | None = None metadata: dict[str, object] | None = None + # polylogue-vf9x: provider-issued cryptographic attestation for a THINKING + # block (Claude's extended-thinking `signature`; Gemini's + # `thoughtSignatures` are the same construct under a different name). + # Since roughly 2026-06 Anthropic ships thinking blocks with an EMPTY + # `thinking` body plus this signature only -- text is genuinely absent + # from the wire, not merely unparsed. Recorded so the fact that the model + # reasoned survives even when no text does; deliberately excluded from + # `_block_content_hash`/lineage prefix signatures (write.py) because the + # provider re-signs on every replay, so including it would break + # citation-anchor and fork-prefix matching for otherwise-identical + # replayed content. + signature: str | None = None # Structured tool-result outcome (keystone): captured from the source's # own outcome fields (Claude toolUseResult.is_error, command exit codes) # so in-session outcomes are readable instead of regex-guessed from text. diff --git a/polylogue/sources/parsers/base_support.py b/polylogue/sources/parsers/base_support.py index 9e161a7dc0..3630184853 100644 --- a/polylogue/sources/parsers/base_support.py +++ b/polylogue/sources/parsers/base_support.py @@ -33,8 +33,24 @@ def content_blocks_from_segments(content: object) -> list[ParsedContentBlock]: seg_type = seg.get("type", "text") if seg_type == "thinking": text = seg.get("thinking") or seg.get("text") or "" - if text: - blocks.append(ParsedContentBlock(type=BlockType.THINKING, text=text)) + signature = seg.get("signature") + # polylogue-vf9x: since roughly 2026-06 the wire ships thinking + # blocks with an empty `thinking` body and only a `signature` -- + # the reasoning genuinely occurred but its text is not on the + # wire (verified against raw ~/.claude/projects JSONL: Feb-2026 + # sessions carry non-empty text, Jul-2026 sessions are 100% + # empty-body/signature-only). Previously this `if text:` guard + # dropped the block outright, silently zeroing thinking_count + # and making the archive look like reasoning stopped -- record + # the block regardless so the fact that the model reasoned here + # (and the signature, for provenance) survives even without text. + blocks.append( + ParsedContentBlock( + type=BlockType.THINKING, + text=text or None, + signature=signature if isinstance(signature, str) and signature else None, + ) + ) elif seg_type == "tool_use": tool_name = seg.get("name") tool_id = seg.get("id") diff --git a/polylogue/sources/parsers/codex.py b/polylogue/sources/parsers/codex.py index 436b9b7b44..e180255c54 100644 --- a/polylogue/sources/parsers/codex.py +++ b/polylogue/sources/parsers/codex.py @@ -1728,6 +1728,86 @@ def _codex_tool_message( return None +def _codex_reasoning_joined_text(value: object) -> str | None: + """Join recoverable text out of a Codex ``reasoning`` record's `summary`/`content`. + + Both fields share the same OpenAI Responses-API shape: either a bare + string, or a list of ``{"type": "summary_text"|"reasoning_text", "text": ...}`` + (or equivalent) dicts. Anything else (encrypted ciphertext, missing + fields) yields no text -- that is a genuine absence, handled by the + caller, not an extraction bug here. + """ + if isinstance(value, str): + return value or None + if not isinstance(value, list): + return None + parts: list[str] = [] + for item in value: + text: object = item.get("text") if isinstance(item, dict) else item + if isinstance(text, str) and text: + parts.append(text) + return "\n\n".join(parts) if parts else None + + +def _codex_reasoning_message( + record: dict[str, object], + *, + index: int, + position: int, + timestamp_fallback: str | int | float | None = None, +) -> ParsedMessage | None: + """Materialize a Codex ``reasoning`` response_item as a THINKING-block message. + + polylogue-vf9x: previously this record type was read only by + ``_compact_response_payload``'s generic session_event compactor, which + has no `reasoning`-specific branch -- neither `summary` nor `content` was + read at all (not merely char-counted: the emitted session_event carries + only ``{source_index, type}``), so every one of the measured 1,182,071 + Codex reasoning records in this operator's raw corpus contributed zero + words to the archive, unreachable from FTS/search even in principle. + + `summary` (OpenAI's human-readable condensation) is present with + recoverable text on ~24% of records measured; `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. Even when neither carries text, the message is + still recorded (block text=None) so the FACT that the model reasoned + here survives -- the same rationale as Claude Code's empty-body thinking + blocks (base_support.py). + + Routed into `messages`/`blocks` (not left as a session_event only) so + reasoning joins the normal content tree: FTS coverage, `thinking_count`, + and the `material_origin`/`BlockType.THINKING` vocabulary every other + origin's reasoning content already uses. + """ + if _record_type(record) != "reasoning": + return None + payload = _record_payload(record) + summary_text = _codex_reasoning_joined_text(payload.get("summary")) + content_text = _codex_reasoning_joined_text(payload.get("content")) + blocks: list[ParsedContentBlock] = [] + if summary_text: + blocks.append(ParsedContentBlock(type=BlockType.THINKING, text=summary_text)) + if content_text and content_text != summary_text: + 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 + timestamp = _iso_or_none(_record_timestamp(record) or timestamp_fallback) + return ParsedMessage( + provider_message_id=f"reasoning-{index}", + role=Role.ASSISTANT, + text=combined_text, + timestamp=timestamp, + position=position, + variant_index=0, + is_active_path=True, + blocks=blocks, + message_type=MessageType.THINKING, + material_origin=MaterialOrigin.ASSISTANT_AUTHORED, + ) + + def _mcp_invocation_tool_name(invocation: dict[str, object]) -> str: server = _string_value(invocation.get("server")) tool = _string_value(invocation.get("tool")) @@ -2255,6 +2335,16 @@ def _parse_records(records: Iterable[object], fallback_id: str) -> ParsedSession messages.append(event_message) message_position += 1 latest_message_timestamp = _newer_timestamp(latest_message_timestamp, event_message.timestamp) + reasoning_message = _codex_reasoning_message( + inner, + index=idx, + position=message_position, + timestamp_fallback=timestamp_fallback, + ) + if reasoning_message is not None: + messages.append(reasoning_message) + message_position += 1 + latest_message_timestamp = _newer_timestamp(latest_message_timestamp, reasoning_message.timestamp) mcp_messages = _codex_mcp_tool_call_messages( inner, index=idx, diff --git a/polylogue/storage/hydrators.py b/polylogue/storage/hydrators.py index 120ad2b175..c1d3b16646 100644 --- a/polylogue/storage/hydrators.py +++ b/polylogue/storage/hydrators.py @@ -111,6 +111,7 @@ def message_from_record( "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, } ) diff --git a/polylogue/storage/runtime/archive/records.py b/polylogue/storage/runtime/archive/records.py index e1add540ec..9081e2057e 100644 --- a/polylogue/storage/runtime/archive/records.py +++ b/polylogue/storage/runtime/archive/records.py @@ -137,6 +137,11 @@ class BlockRecord(BaseModel): # (see core.enums.ToolResultUnknownReason). NULL means either the # outcome IS known, or this read path did not select the column. tool_result_outcome_unknown_reason: str | None = None + # polylogue-vf9x (v49): provider-issued cryptographic attestation for a + # THINKING block (Claude's extended-thinking signature; Gemini's + # thoughtSignatures are the same construct). NULL when the wire carried + # none, or this read path did not select the column. + signature: str | None = None @field_validator("type", mode="before") @classmethod diff --git a/polylogue/storage/sqlite/archive_tiers/archive_tiers_specs.py b/polylogue/storage/sqlite/archive_tiers/archive_tiers_specs.py index 7e50d67ecb..59776a298c 100644 --- a/polylogue/storage/sqlite/archive_tiers/archive_tiers_specs.py +++ b/polylogue/storage/sqlite/archive_tiers/archive_tiers_specs.py @@ -2,7 +2,7 @@ Defines the single source of truth for: - messages table structure (29 writable columns + 1 GENERATED message_id) - - blocks table structure (14 writable columns + 1 GENERATED block_id) + - blocks table structure (16 writable columns + 1 GENERATED block_id) - Other key tables (sessions, session_events, etc.) Each spec drives INSERT/SELECT generation and typed row extraction. @@ -84,7 +84,8 @@ def _make_blocks_spec() -> TableColumnSpec: The blocks table structure (from schema): session_id, message_id, position, block_type, text, tool_name, tool_id, tool_input, semantic_type, media_type, language, tool_result_is_error, - tool_result_exit_code, content_hash + tool_result_exit_code, tool_result_outcome_unknown_reason, signature, + content_hash GENERATED (not writable): block_id, tool_command, tool_path, search_text, tool_detail_text @@ -105,6 +106,7 @@ def _make_blocks_spec() -> TableColumnSpec: ColumnSpec("tool_result_is_error", "INTEGER"), ColumnSpec("tool_result_exit_code", "INTEGER"), ColumnSpec("tool_result_outcome_unknown_reason", "TEXT"), + ColumnSpec("signature", "TEXT"), ColumnSpec("content_hash", "BLOB"), ColumnSpec("tool_command", "TEXT", is_generated=True), ColumnSpec("tool_path", "TEXT", is_generated=True), diff --git a/polylogue/storage/sqlite/archive_tiers/index.py b/polylogue/storage/sqlite/archive_tiers/index.py index bb3788c729..0e67dcc142 100644 --- a/polylogue/storage/sqlite/archive_tiers/index.py +++ b/polylogue/storage/sqlite/archive_tiers/index.py @@ -129,7 +129,36 @@ # # Both depend on parser/pricing semantics to populate/repair existing rows -- # SEMANTIC_REPARSE, matching the v42/v44/v45/v46/v47/v48 precedent above. -INDEX_SCHEMA_VERSION = 49 +# +# polylogue-vf9x: v50 adds blocks.signature (nullable TEXT) and fixes two +# independent reasoning/thinking-content-loss defects found via a full-corpus +# audit: +# - Claude Code (base_support.py `content_blocks_from_segments`): since +# roughly 2026-06 the wire ships THINKING segments with an empty +# `thinking` body and a `signature` only. An `if text:` guard dropped the +# whole block, silently zeroing `thinking_count` archive-wide for every +# 2026-06+ session -- a false "reasoning declined" signal, not a real +# absence. The block is now always recorded (text=NULL when the wire +# carries none, signature captured when present). +# - Codex (codex.py): standalone `reasoning` response_item records were +# read only by the generic session_event compactor, which has no +# `reasoning`-specific branch -- summary/content were never read at all +# (not merely char-counted), so 100% of Codex reasoning text was +# discarded and unreachable from FTS/search. `reasoning` records are now +# materialized as a THINKING-block message (summary text -- the ~24% +# recoverable case -- or content text when present; text=NULL when only +# encrypted_content survives). +# Both are pure parser-semantics changes over the same already-declared +# `blocks.block_type='thinking'` vocabulary plus one additive nullable +# column -- the v42/v44/v45/v46/v48/v49 "values depend on parser semantics, +# no clone-safe SQL delta" precedent. `signature` is deliberately excluded +# from `_block_content_hash` and the lineage prefix signature (write.py) +# because providers re-sign on every replay; including it would break +# citation-anchor and fork-prefix matching for otherwise-identical replayed +# content. Resolving existing rows (recovering historical thinking/reasoning +# content) requires `polylogue ops reset --index && polylogued run` -- +# deliberately NOT executed by this declaration. +INDEX_SCHEMA_VERSION = 50 # polylogue-v6i3: shared WHEN-clause fragment gating the blocks_command_trigram # trigger BODIES on the same dedicated bulk-build guard row messages_fts's @@ -436,6 +465,17 @@ -- an unknown reason". tool_result_outcome_unknown_reason TEXT CHECK ({nullable_check("tool_result_outcome_unknown_reason", ToolResultUnknownReason)}), + -- polylogue-vf9x (v49): provider-issued cryptographic attestation for a + -- THINKING block (Claude's extended-thinking `signature`; Gemini's + -- `thoughtSignatures` are the same construct). Populated whenever the + -- wire carries one -- notably including the empty-body thinking blocks + -- Claude Code has shipped since ~2026-06, where this is the only + -- surviving evidence besides the block's existence. Deliberately + -- excluded from content_hash below and from the lineage prefix + -- signature (write.py): providers re-sign on every replay, so including + -- it would break fork-prefix/citation-anchor matching for otherwise- + -- identical replayed content. + signature TEXT, -- svfj: the citation anchor atom. Hashes canonical block EVIDENCE only -- (type, text, tool_name, canonical tool_input, semantic/media/language, -- is_error, exit_code) -- deliberately EXCLUDING session_id/message_id/ diff --git a/polylogue/storage/sqlite/archive_tiers/write.py b/polylogue/storage/sqlite/archive_tiers/write.py index ae1428651f..acc708242f 100644 --- a/polylogue/storage/sqlite/archive_tiers/write.py +++ b/polylogue/storage/sqlite/archive_tiers/write.py @@ -1891,6 +1891,7 @@ def _build_block_rows( is_error = getattr(block, "is_error", None) exit_code = getattr(block, "exit_code", None) outcome_unknown_reason = _enum_value(block.outcome_unknown_reason) + signature = getattr(block, "signature", None) # Tuple built in order defined by spec.writable_columns rows.append( ( @@ -1908,6 +1909,7 @@ def _build_block_rows( _sqlite_bool(is_error), exit_code, outcome_unknown_reason, + _sqlite_text(signature), _block_content_hash( block_type=block_type.value, text=block.text, @@ -1946,7 +1948,7 @@ def _write_blocks( """Write block rows using table-driven column specification. The blocks table column spec (archive_tiers_specs.BLOCKS_SPEC) defines: - - writable_columns: the ordered list of columns to INSERT (14 total) + - writable_columns: the ordered list of columns to INSERT (16 total) - The column names and placeholders are generated from the spec - The tuple order is derived from the spec's writable_columns order diff --git a/polylogue/storage/sqlite/lifecycle.py b/polylogue/storage/sqlite/lifecycle.py index b4a5afcc04..8d5fa0fa80 100644 --- a/polylogue/storage/sqlite/lifecycle.py +++ b/polylogue/storage/sqlite/lifecycle.py @@ -478,6 +478,29 @@ class IndexDeltaDeclarationReport(TypedDict): # `polylogue ops reset --index && polylogued run`. classes=(DerivedDeltaClass.SEMANTIC_REPARSE,), ), + IndexDeltaDeclaration( + version=50, + # polylogue-vf9x: adds blocks.signature (nullable TEXT) and fixes two + # independent reasoning/thinking-content-loss defects (see index.py's + # v50 header comment for the full writeup): + # - Claude Code: an `if text:` guard in base_support.py's + # `content_blocks_from_segments` dropped every THINKING segment + # whose wire body is empty-string-plus-signature-only -- the + # shape Claude has shipped since ~2026-06 -- silently zeroing + # `thinking_count` for every affected session. + # - Codex: standalone `reasoning` response_item records were read + # only by the generic session_event compactor (no + # `reasoning`-specific branch existed), so summary/content text + # was never read into any surface at all. + # Both changes require re-parsing already-acquired raw evidence to + # recover the previously-dropped/discarded text, so this is the same + # v42/v44/v45/v46/v48/v49 "values depend on parser semantics" shape -- + # not a free clone-safe fast-forward, even though `signature` is a + # real additive column. `polylogue ops reset --index && polylogued + # run` is required to recover historical thinking/reasoning content; + # deliberately NOT executed by this declaration. + classes=(DerivedDeltaClass.SEMANTIC_REPARSE,), + ), ) diff --git a/polylogue/storage/sqlite/queries/attachment_blocks.py b/polylogue/storage/sqlite/queries/attachment_blocks.py index fad866d004..98b8d41da4 100644 --- a/polylogue/storage/sqlite/queries/attachment_blocks.py +++ b/polylogue/storage/sqlite/queries/attachment_blocks.py @@ -32,7 +32,8 @@ async def get_blocks( semantic_type, tool_result_is_error, tool_result_exit_code, - tool_result_outcome_unknown_reason + tool_result_outcome_unknown_reason, + signature FROM blocks WHERE message_id IN ({placeholders}) ORDER BY message_id, position diff --git a/polylogue/storage/sqlite/queries/mappers_archive.py b/polylogue/storage/sqlite/queries/mappers_archive.py index b60b672725..d7cfdad2bf 100644 --- a/polylogue/storage/sqlite/queries/mappers_archive.py +++ b/polylogue/storage/sqlite/queries/mappers_archive.py @@ -129,6 +129,7 @@ def _row_to_content_block(row: sqlite3.Row) -> BlockRecord: tool_result_is_error=_row_int(row, "tool_result_is_error"), tool_result_exit_code=_row_int(row, "tool_result_exit_code"), tool_result_outcome_unknown_reason=_row_text(row, "tool_result_outcome_unknown_reason"), + signature=_row_text(row, "signature"), ) diff --git a/tests/unit/core/test_models.py b/tests/unit/core/test_models.py index 6f05b8d7d8..cf64bceb5f 100644 --- a/tests/unit/core/test_models.py +++ b/tests/unit/core/test_models.py @@ -367,6 +367,7 @@ def test_from_record_preserves_structured_content_block_semantics(self) -> None: "media_type": None, "metadata": {"path": "/workspace/polylogue/README.md"}, "semantic_type": "file_read", + "signature": None, } ] diff --git a/tests/unit/sources/test_parsers_base.py b/tests/unit/sources/test_parsers_base.py index 37e9b589ba..05e83cbf5a 100644 --- a/tests/unit/sources/test_parsers_base.py +++ b/tests/unit/sources/test_parsers_base.py @@ -110,6 +110,41 @@ def test_content_blocks_from_segments_classifies_code_and_tool_blocks() -> None: assert blocks[5].media_type == "application/pdf" +def test_content_blocks_from_segments_keeps_empty_body_thinking_with_signature() -> None: + """polylogue-vf9x: since ~2026-06 Claude ships THINKING segments with an + empty ``thinking`` body and a ``signature`` only (real wire shape, + verified against raw ~/.claude/projects JSONL). Previously an + ``if text:`` guard dropped the block entirely, silently zeroing + ``thinking_count`` and making the archive look like reasoning stopped. + The block must still be recorded -- text=None, signature preserved -- + so the fact that the model reasoned here survives. + """ + blocks = content_blocks_from_segments( + [ + { + "type": "thinking", + "thinking": "", + "signature": "CAIStwIKhwEIEBgCKkAJIkle5IARlxfdMsvM8IvhleRSuJ61Xvgm", + }, + ] + ) + + assert len(blocks) == 1 + assert blocks[0].type == "thinking" + assert blocks[0].text is None + assert blocks[0].signature == "CAIStwIKhwEIEBgCKkAJIkle5IARlxfdMsvM8IvhleRSuJ61Xvgm" + + +def test_content_blocks_from_segments_keeps_thinking_with_neither_text_nor_signature() -> None: + """Degenerate wire shape (no signature either) still preserves the block.""" + blocks = content_blocks_from_segments([{"type": "thinking", "thinking": ""}]) + + assert len(blocks) == 1 + assert blocks[0].type == "thinking" + assert blocks[0].text is None + assert blocks[0].signature is None + + def test_tool_result_web_search_knowledge_items_become_search_result_constructs() -> None: """polylogue-zocm GAP 2: web_search tool_result content carries retrieved ``{type: knowledge, ...}`` entries the provider read but did not diff --git a/tests/unit/sources/test_parsers_codex.py b/tests/unit/sources/test_parsers_codex.py index f0ebf48e83..f413807160 100644 --- a/tests/unit/sources/test_parsers_codex.py +++ b/tests/unit/sources/test_parsers_codex.py @@ -432,6 +432,87 @@ def test_state_records_skipped(self) -> None: result = parse(payload, "fallback") assert len(result.messages) == 1 + def test_reasoning_summary_text_becomes_thinking_block(self) -> None: + """polylogue-vf9x: real wire shape (anonymized from an operator rollout). + + Codex ships a standalone ``reasoning`` response_item with a + human-readable ``summary`` (a list of ``summary_text`` parts) and an + essentially-always-null ``content``, with the full trace only + recoverable as ciphertext in ``encrypted_content``. Previously this + record was read only by the generic session_event compactor -- which + has no reasoning-specific branch -- so the summary text was silently + discarded and never reached FTS/search. + """ + payload = [ + { + "type": "response_item", + "payload": { + "type": "reasoning", + "summary": [{"type": "summary_text", "text": "**Preparing to analyze git changes**"}], + "content": None, + "encrypted_content": "gAAAAABozSvL2wVUn4Ixsm34" * 4, + }, + }, + ] + result = parse(payload, "fallback") + + thinking_messages = [m for m in result.messages if m.message_type is MessageType.THINKING] + assert len(thinking_messages) == 1 + message = thinking_messages[0] + assert message.role == Role.ASSISTANT + assert message.material_origin is MaterialOrigin.ASSISTANT_AUTHORED + assert message.text == "**Preparing to analyze git changes**" + assert len(message.blocks) == 1 + assert message.blocks[0].type == BlockType.THINKING + assert message.blocks[0].text == "**Preparing to analyze git changes**" + + def test_reasoning_with_only_encrypted_content_still_recorded(self) -> None: + """No recoverable text (summary absent, content null) -- the block + still exists with text=None so the FACT that reasoning occurred + survives, matching the empty-body Claude Code thinking fix. + """ + payload = [ + { + "type": "response_item", + "payload": { + "type": "reasoning", + "summary": [], + "content": None, + "encrypted_content": "gAAAAABozSvL2wVUn4Ixsm34" * 4, + }, + }, + ] + result = parse(payload, "fallback") + + thinking_messages = [m for m in result.messages if m.message_type is MessageType.THINKING] + assert len(thinking_messages) == 1 + message = thinking_messages[0] + assert message.text is None + assert len(message.blocks) == 1 + assert message.blocks[0].type == BlockType.THINKING + assert message.blocks[0].text is None + + def test_reasoning_content_text_used_when_present(self) -> None: + """`content` (the full trace) is read too, when the wire carries it + as plain text rather than encrypted ciphertext.""" + payload = [ + { + "type": "response_item", + "payload": { + "type": "reasoning", + "summary": [{"type": "summary_text", "text": "short summary"}], + "content": [{"type": "reasoning_text", "text": "the full reasoning trace"}], + }, + }, + ] + result = parse(payload, "fallback") + + thinking_messages = [m for m in result.messages if m.message_type is MessageType.THINKING] + assert len(thinking_messages) == 1 + message = thinking_messages[0] + block_texts = [b.text for b in message.blocks] + assert block_texts == ["short summary", "the full reasoning trace"] + def test_multiple_content_blocks(self) -> None: payload = [ { From 83d9579c1dcef1a92d70afd9b280c6e91510edf7 Mon Sep 17 00:00:00 2001 From: Sinity Date: Fri, 31 Jul 2026 13:54:24 +0200 Subject: [PATCH 2/2] chore(topology): regenerate projection for pre-existing orphan files Problem: `devtools verify topology` blocked (2 orphans: polylogue/cli/commands/compare.py, polylogue/insights/measurement/registered_metrics.py) on origin/master already, from dd912a78a ("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 --- docs/plans/topology-target.yaml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/plans/topology-target.yaml b/docs/plans/topology-target.yaml index e14a17f409..eae4b03734 100644 --- a/docs/plans/topology-target.yaml +++ b/docs/plans/topology-target.yaml @@ -3367,12 +3367,12 @@ files: target: polylogue/sources/parsers/base.py owner: stable - path: polylogue/sources/parsers/base_models.py - loc: 446 + loc: 458 target: polylogue/sources/parsers/base_models.py owner: stable cross_cut: { lifecycle: model } - path: polylogue/sources/parsers/base_support.py - loc: 313 + loc: 329 target: polylogue/sources/parsers/base_support.py owner: stable - path: polylogue/sources/parsers/beads.py @@ -3428,7 +3428,7 @@ files: target: polylogue/sources/parsers/claude/orchestration.py owner: stable - path: polylogue/sources/parsers/codex.py - loc: 2481 + loc: 2571 target: polylogue/sources/parsers/codex.py owner: stable - path: polylogue/sources/parsers/codex_state.py @@ -3737,7 +3737,7 @@ files: target: polylogue/storage/fts/sql.py owner: stable - path: polylogue/storage/hydrators.py - loc: 255 + loc: 256 target: polylogue/storage/hydrators.py owner: storage-root reason: storage-root cross-cutting helper @@ -4005,7 +4005,7 @@ files: target: polylogue/storage/runtime/archive/__init__.py owner: stable - path: polylogue/storage/runtime/archive/records.py - loc: 408 + loc: 413 target: polylogue/storage/runtime/archive/records.py owner: stable - path: polylogue/storage/runtime/raw/__init__.py @@ -4120,7 +4120,7 @@ files: target: polylogue/storage/sqlite/archive_tiers/archive_plan.py owner: stable - path: polylogue/storage/sqlite/archive_tiers/archive_tiers_specs.py - loc: 131 + loc: 133 target: polylogue/storage/sqlite/archive_tiers/archive_tiers_specs.py owner: stable - path: polylogue/storage/sqlite/archive_tiers/bootstrap.py @@ -4148,7 +4148,7 @@ files: target: polylogue/storage/sqlite/archive_tiers/embeddings.py owner: stable - path: polylogue/storage/sqlite/archive_tiers/index.py - loc: 2065 + loc: 2105 target: polylogue/storage/sqlite/archive_tiers/index.py owner: stable - path: polylogue/storage/sqlite/archive_tiers/index_convergence.py @@ -4224,7 +4224,7 @@ files: target: polylogue/storage/sqlite/archive_tiers/user_write.py owner: stable - path: polylogue/storage/sqlite/archive_tiers/write.py - loc: 6192 + loc: 6194 target: polylogue/storage/sqlite/archive_tiers/write.py owner: stable - path: polylogue/storage/sqlite/async_sqlite.py @@ -4256,7 +4256,7 @@ files: target: polylogue/storage/sqlite/finding_provenance.py owner: stable - path: polylogue/storage/sqlite/lifecycle.py - loc: 635 + loc: 658 target: polylogue/storage/sqlite/lifecycle.py owner: stable - path: polylogue/storage/sqlite/maintenance.py @@ -4292,7 +4292,7 @@ files: target: polylogue/storage/sqlite/queries/artifacts.py owner: stable - path: polylogue/storage/sqlite/queries/attachment_blocks.py - loc: 57 + loc: 58 target: polylogue/storage/sqlite/queries/attachment_blocks.py owner: stable - path: polylogue/storage/sqlite/queries/attachment_mutations.py @@ -4324,7 +4324,7 @@ files: target: polylogue/storage/sqlite/queries/mappers.py owner: stable - path: polylogue/storage/sqlite/queries/mappers_archive.py - loc: 286 + loc: 287 target: polylogue/storage/sqlite/queries/mappers_archive.py owner: stable - path: polylogue/storage/sqlite/queries/mappers_insight_aggregates.py