Skip to content

fix: preserve ThinkingPart fields in OpenCode storage round-trip (#156) - #222

Closed
Million-mo wants to merge 1 commit into
wolf1069b:develop/agenticfrom
Million-mo:fix/issue-156-thinkingpart-storage-fields
Closed

fix: preserve ThinkingPart fields in OpenCode storage round-trip (#156)#222
Million-mo wants to merge 1 commit into
wolf1069b:develop/agenticfrom
Million-mo:fix/issue-156-thinkingpart-storage-fields

Conversation

@Million-mo

Copy link
Copy Markdown
Collaborator

Summary

Fixes #156

ThinkingPart.id, provider_name, signature, and provider_details were silently dropped during OpenCode storage serialization. The write path only saved content as ReasoningPart.text, and the read path only restored content — all other fields defaulted to None.

This caused send-back to degrade to tags mode: pydantic-ai checks id + provider_name to decide between field mode (reasoning_content) and tags mode. Without these fields, thinking content was wrapped in tags instead of sent via the proper reasoning_content field.

Fix

Store the extra fields in ReasoningPart.metadata during write, and restore them when constructing ThinkingPart during read. The metadata field already exists on ReasoningPart and is a dict[str, Any], making it a natural extensibility bucket without model changes.

Changes

  • provider.py:464-474: Write ThinkingPart.id/provider_name/signature/provider_details into ReasoningPart.metadata
  • helpers.py:328-330: Read back metadata fields when constructing ThinkingPart
  • test_opencode_thinking_roundtrip.py: 6 new tests covering all fields, partial metadata, empty metadata, coexistence with TextPart, and empty text skip

Backward Compatibility

Old data without metadata produces ThinkingPart with None defaults — same behavior as before this fix.

Test Plan

  • 6 new tests pass (all fields, no metadata, partial, empty, coexistence, empty text)
  • ruff check + format pass
  • mypy passes on both changed source files

Relationship to #155 and #174

Three independent fixes, no overlap.

ThinkingPart.id, provider_name, signature, and provider_details were
silently dropped during OpenCode storage serialization. The write path
(provider.py) only saved content as ReasoningPart.text, and the read
path (helpers.py) only restored content — all other fields defaulted
to None.

This caused send-back to degrade to tags mode: pydantic-ai's
_map_response_thinking_part() checks id + provider_name to decide
between field mode (reasoning_content) and tags mode. Without these
fields, thinking content was wrapped in <think> tags instead of sent
via the proper reasoning_content field.

Fix: store the extra fields in ReasoningPart.metadata during write,
and restore them when constructing ThinkingPart during read. The
metadata field already exists on ReasoningPart and is a dict[str, Any],
making it a natural extensibility bucket without model changes.

Backward compatible: old data without metadata produces ThinkingPart
with None defaults, same as before.

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request implements the preservation of ThinkingPart fields (id, provider_name, signature, and provider_details) through the OpenCode storage round-trip cycle by mapping them to and from the metadata field of ReasoningPart. It also adds comprehensive unit tests to verify this behavior. The reviewer suggests simplifying the manual extraction and construction of metadata dictionaries in both helpers.py and provider.py using loops or dictionary comprehensions to improve maintainability and idiomaticity.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +330 to +341
thinking_kwargs: dict[str, Any] = {"content": part.text}
meta = part.metadata
if meta:
if meta.get("thinking_id") is not None:
thinking_kwargs["id"] = meta["thinking_id"]
if meta.get("provider_name") is not None:
thinking_kwargs["provider_name"] = meta["provider_name"]
if meta.get("signature") is not None:
thinking_kwargs["signature"] = meta["signature"]
if meta.get("provider_details") is not None:
thinking_kwargs["provider_details"] = meta["provider_details"]
response_parts.append(ThinkingPart(**thinking_kwargs))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The manual extraction of metadata fields can be simplified and made more maintainable by using a dictionary mapping and a loop. Additionally, when processing messages loaded from storage where 'content' may be None, use defensive 'or ""' checks to normalize None to an empty string. Also, avoid implicit truthiness checks on optional dictionary/mapping parameters and use explicit 'is not None' checks instead.

Suggested change
thinking_kwargs: dict[str, Any] = {"content": part.text}
meta = part.metadata
if meta:
if meta.get("thinking_id") is not None:
thinking_kwargs["id"] = meta["thinking_id"]
if meta.get("provider_name") is not None:
thinking_kwargs["provider_name"] = meta["provider_name"]
if meta.get("signature") is not None:
thinking_kwargs["signature"] = meta["signature"]
if meta.get("provider_details") is not None:
thinking_kwargs["provider_details"] = meta["provider_details"]
response_parts.append(ThinkingPart(**thinking_kwargs))
thinking_kwargs: dict[str, Any] = {"content": part.text or ""}
if part.metadata is not None:
mapping = {
"thinking_id": "id",
"provider_name": "provider_name",
"signature": "signature",
"provider_details": "provider_details",
}
for meta_key, part_key in mapping.items():
if (val := part.metadata.get(meta_key)) is not None:
thinking_kwargs[part_key] = val
response_parts.append(ThinkingPart(**thinking_kwargs))
References
  1. PEP 8 recommends writing clean, idiomatic, and maintainable code, avoiding redundant or repetitive conditional blocks where a loop or comprehension is more appropriate. (link)
  2. When processing messages loaded from storage where 'content' may be None, use defensive 'or ""' checks to normalize None to an empty string so that falsy values can be cleanly filtered by guards.
  3. Avoid using implicit truthiness checks or intermediate boolean variables to check for the existence of optional dictionary/mapping parameters when type-narrowing is required. Use explicit is not None checks directly in the conditional statement so that static type checkers like mypy can correctly narrow the type.

Comment on lines +465 to +473
reasoning_metadata: dict[str, Any] = {}
if part.id is not None:
reasoning_metadata["thinking_id"] = part.id
if part.provider_name is not None:
reasoning_metadata["provider_name"] = part.provider_name
if part.signature is not None:
reasoning_metadata["signature"] = part.signature
if part.provider_details is not None:
reasoning_metadata["provider_details"] = part.provider_details

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The manual construction of the metadata dictionary can be simplified using a dictionary comprehension. This is more idiomatic and concise.

                        reasoning_metadata = {
                            k: v
                            for k, v in {
                                "thinking_id": part.id,
                                "provider_name": part.provider_name,
                                "signature": part.signature,
                                "provider_details": part.provider_details,
                            }.items()
                            if v is not None
                        }
References
  1. PEP 8 recommends writing clean, idiomatic, and maintainable code, avoiding redundant or repetitive conditional blocks where a loop or comprehension is more appropriate. (link)

@Million-mo

Copy link
Copy Markdown
Collaborator Author

Closing in favor of a new PR rebased on main. The original branch was based on develop/agentic which has diverged significantly from main, causing conflicts. A new PR will be created from a clean main-based branch.

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