Skip to content

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

Closed
Million-mo wants to merge 3 commits into
wolf1069b:mainfrom
Million-mo:fix/issue-156-thinkingpart-storage-main
Closed

fix: preserve ThinkingPart fields in OpenCode storage round-trip (#156)#232
Million-mo wants to merge 3 commits into
wolf1069b:mainfrom
Million-mo:fix/issue-156-thinkingpart-storage-main

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 (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.

Solution

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.

Changes

File Change
src/agentpool_storage/opencode_provider/provider.py Write id/provider_name/signature/provider_details into ReasoningPart.metadata
src/agentpool_storage/opencode_provider/helpers.py Restore fields from metadata when constructing ThinkingPart
tests/sessions/test_opencode_thinking_roundtrip.py New test file covering round-trip scenarios

Rebase Note

Rebased from develop/agentic onto main — the original PR (#222) was closed due to branch divergence. Applied cleanly with no conflicts.

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 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

Copy link
Copy Markdown

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

Fork PRs don't have access to GitHub Actions secrets, so OPENAI_API_KEY
is empty. The OpenAI SDK client constructor validates the key at init
time and raises 'Missing credentials' before VCR can intercept HTTP
calls. This causes VCR and E2E smoke tests to fail on all fork PRs.

Inject a dummy key when the real one is absent. VCR cassette replay and
TestModel-based tests never make real HTTP calls, so the dummy key is
never used. When the real secret IS available (same-repo PRs), this is
a no-op.
@Million-mo
Million-mo force-pushed the fix/issue-156-thinkingpart-storage-main branch from 949968b to 0e78c01 Compare July 20, 2026 03:08
@Million-mo

Copy link
Copy Markdown
Collaborator Author

/gemini review

@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 ensures that ThinkingPart metadata fields (id, provider_name, signature, and provider_details) are preserved during the OpenCode storage round-trip. It also updates the test configuration to inject a dummy OPENAI_API_KEY for fork PRs to prevent test failures, and adds a new test suite verifying the round-trip behavior. Feedback was provided to simplify the metadata extraction logic in helpers.py using a dictionary mapping and explicit 'is not None' checks to assist static type checkers.

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

We can simplify the mapping of metadata keys to ThinkingPart arguments by using a dictionary mapping and a loop. This reduces nested if statements and makes the code more maintainable. Ensure we use an explicit is not None check on part.metadata directly in the conditional statement to allow static type checkers like mypy to narrow the type correctly, avoiding implicit truthiness checks or intermediate variables.

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}
if part.metadata is not None:
mapping = {
"thinking_id": "id",
"provider_name": "provider_name",
"signature": "signature",
"provider_details": "provider_details",
}
for meta_key, kwarg_key in mapping.items():
if (val := part.metadata.get(meta_key)) is not None:
thinking_kwargs[kwarg_key] = val
response_parts.append(ThinkingPart(**thinking_kwargs))
References
  1. 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.

VCR cassettes were recorded against api.ai.rootcloud.info, but fork PRs
don't have the OPENAI_BASE_URL secret. Without it, the OpenAI SDK targets
api.openai.com, and VCR can't replay the cassette (even with match_on=
['method']) — resulting in 'Connection error' on all VCR tests.
@Million-mo

Copy link
Copy Markdown
Collaborator Author

Superseded by #241 — combined #155 and #156 into a single PR rebased onto latest main.

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.

[Bug] OpenCode 存储往返丢失 ThinkingPart.id 和 provider_name,导致 send-back 降级为 tags 模式

1 participant