Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/agentpool_storage/opencode_provider/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,18 @@ def _build_assistant_pydantic_messages(
response_parts.append(PydanticTextPart(content=part.text))
elif isinstance(part, ReasoningPart):
if part.text:
response_parts.append(ThinkingPart(content=part.text))
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))
Comment on lines +330 to +341

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.

elif isinstance(part, ToolPart):
tc_part = ToolCallPart(
tool_name=part.tool,
Expand Down
10 changes: 10 additions & 0 deletions src/agentpool_storage/opencode_provider/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -462,11 +462,21 @@ async def _write_message( # noqa: PLR0915
part_file.write_text(anyenv.dump_json(dct, indent=True), encoding="utf-8")

elif isinstance(part, ThinkingPart):
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
reasoning_part = OpenCodeReasoningPart(
id=part_id,
session_id=session_id,
message_id=message_id,
text=part.content,
metadata=reasoning_metadata or None,
time=TimeStartEndOptional(start=now_ms),
)
part_file = parts_dir / f"{part_id}.json"
Expand Down
24 changes: 24 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,21 @@

TEST_RESPONSE = "I am a test response"

# Inject dummy OPENAI_API_KEY and OPENAI_BASE_URL when the real ones are absent
# (e.g. fork PRs where GitHub Actions secrets are not available). VCR cassette
# replay and TestModel-based tests never make real HTTP calls, but the OpenAI SDK
# client constructor validates the key at init time — without this shim, VCR and
# E2E smoke tests fail with "Missing credentials" on fork PRs.
#
# OPENAI_BASE_URL must point to the same endpoint the cassettes were recorded
# against. VCR's match_on is ["method"] but the SDK must still target the same
# URL so VCR can find and replay the recorded interaction.
# When the real secrets ARE available (same-repo PRs), this is a no-op.
if not os.environ.get("OPENAI_API_KEY"):
os.environ["OPENAI_API_KEY"] = "sk-test-dummy-for-ci"
if not os.environ.get("OPENAI_BASE_URL"):
os.environ["OPENAI_BASE_URL"] = "http://api.ai.rootcloud.info/v1"


@pytest.fixture
def default_model() -> str:
Expand Down Expand Up @@ -266,6 +281,15 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
reason="OPENAI_API_KEY not set — skipping credential-dependent test",
)
)
if (
"real_model" in item.keywords
and os.environ.get("OPENAI_API_KEY") == "sk-test-dummy-for-ci"
):
item.add_marker(
pytest.mark.skip(
reason="OPENAI_API_KEY is a dummy CI value — skipping real model test",
)
)
if "incompatible_with_thinking" in item.keywords and is_thinking_model:
item.add_marker(
pytest.mark.skip(
Expand Down
197 changes: 197 additions & 0 deletions tests/sessions/test_opencode_thinking_roundtrip.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"""Tests for ThinkingPart field preservation through OpenCode storage round-trip.

Verifies that ThinkingPart.id, provider_name, signature, and provider_details
survive the ReasoningPart → JSON → ReasoningPart → ThinkingPart cycle.
See issue #156.
"""

from __future__ import annotations

from datetime import UTC, datetime

from pydantic_ai.messages import ModelResponse, ThinkingPart
import pytest

from agentpool_server.opencode_server.models.message import (
AssistantMessage,
MessagePath,
MessageTime,
)
from agentpool_server.opencode_server.models.parts import ReasoningPart
from agentpool_storage.opencode_provider.helpers import (
_build_assistant_pydantic_messages,
)


@pytest.fixture
def timestamp() -> datetime:
return datetime(2025, 7, 14, 12, 0, 0, tzinfo=UTC)


@pytest.fixture
def assistant_msg() -> AssistantMessage:
return AssistantMessage(
id="msg_test001",
session_id="ses_test001",
parent_id="msg_parent001",
model_id="svc/kimi-k2",
provider_id="openai",
path=MessagePath(cwd="/tmp", root="/tmp"),
time=MessageTime(created=1720958400000, completed=1720958401000),
)


class TestThinkingPartRoundTrip:
"""Verify ThinkingPart fields survive storage round-trip via metadata."""

def test_all_fields_preserved(
self, assistant_msg: AssistantMessage, timestamp: datetime
) -> None:
"""All ThinkingPart fields are preserved through ReasoningPart.metadata."""
reasoning = ReasoningPart(
id="prt_001",
session_id="ses_test001",
message_id="msg_test001",
text="The user asks about Python testing.",
metadata={
"thinking_id": "resp_001",
"provider_name": "openai",
"signature": "sig_abc123",
"provider_details": {"raw_content": ["The user asks about Python testing."]},
},
time=None,
)
parts = [reasoning]

messages = _build_assistant_pydantic_messages(assistant_msg, parts, timestamp)

assert len(messages) == 1
assert isinstance(messages[0], ModelResponse)
assert len(messages[0].parts) == 1
thinking = messages[0].parts[0]
assert isinstance(thinking, ThinkingPart)
assert thinking.content == "The user asks about Python testing."
assert thinking.id == "resp_001"
assert thinking.provider_name == "openai"
assert thinking.signature == "sig_abc123"
assert thinking.provider_details == {"raw_content": ["The user asks about Python testing."]}

def test_no_metadata_returns_plain_thinkingpart(
self, assistant_msg: AssistantMessage, timestamp: datetime
) -> None:
"""ReasoningPart without metadata produces ThinkingPart with default None fields."""
reasoning = ReasoningPart(
id="prt_002",
session_id="ses_test001",
message_id="msg_test001",
text="reasoning without metadata",
)
parts = [reasoning]

messages = _build_assistant_pydantic_messages(assistant_msg, parts, timestamp)

thinking = messages[0].parts[0]
assert isinstance(thinking, ThinkingPart)
assert thinking.content == "reasoning without metadata"
assert thinking.id is None
assert thinking.provider_name is None
assert thinking.signature is None
assert thinking.provider_details is None

def test_partial_metadata_preserved(
self, assistant_msg: AssistantMessage, timestamp: datetime
) -> None:
"""Only provided metadata fields are set; others default to None."""
reasoning = ReasoningPart(
id="prt_003",
session_id="ses_test001",
message_id="msg_test001",
text="partial reasoning",
metadata={
"thinking_id": "resp_003",
"provider_name": "openai",
},
)
parts = [reasoning]

messages = _build_assistant_pydantic_messages(assistant_msg, parts, timestamp)

thinking = messages[0].parts[0]
assert isinstance(thinking, ThinkingPart)
assert thinking.content == "partial reasoning"
assert thinking.id == "resp_003"
assert thinking.provider_name == "openai"
assert thinking.signature is None
assert thinking.provider_details is None

def test_empty_metadata_dict_defaults_to_none(
self, assistant_msg: AssistantMessage, timestamp: datetime
) -> None:
"""Empty metadata dict produces ThinkingPart with all None extra fields."""
reasoning = ReasoningPart(
id="prt_004",
session_id="ses_test001",
message_id="msg_test001",
text="empty meta reasoning",
metadata={},
)
parts = [reasoning]

messages = _build_assistant_pydantic_messages(assistant_msg, parts, timestamp)

thinking = messages[0].parts[0]
assert isinstance(thinking, ThinkingPart)
assert thinking.content == "empty meta reasoning"
assert thinking.id is None
assert thinking.provider_name is None

def test_thinking_and_text_parts_coexist(
self, assistant_msg: AssistantMessage, timestamp: datetime
) -> None:
"""ThinkingPart and TextPart in the same message are both preserved."""
from agentpool_server.opencode_server.models.parts import TextPart

reasoning = ReasoningPart(
id="prt_005",
session_id="ses_test001",
message_id="msg_test001",
text="thinking here",
metadata={"thinking_id": "resp_005", "provider_name": "openai"},
)
text = TextPart(
id="prt_006",
session_id="ses_test001",
message_id="msg_test001",
text="answer here",
)
parts = [reasoning, text]

messages = _build_assistant_pydantic_messages(assistant_msg, parts, timestamp)

assert len(messages) == 1
assert isinstance(messages[0], ModelResponse)
assert len(messages[0].parts) == 2
thinking = messages[0].parts[0]
text_part = messages[0].parts[1]
assert isinstance(thinking, ThinkingPart)
assert thinking.content == "thinking here"
assert thinking.id == "resp_005"
assert thinking.provider_name == "openai"
assert text_part.content == "answer here"

def test_empty_text_reasoning_skipped(
self, assistant_msg: AssistantMessage, timestamp: datetime
) -> None:
"""ReasoningPart with empty text is skipped (existing behavior)."""
reasoning = ReasoningPart(
id="prt_007",
session_id="ses_test001",
message_id="msg_test001",
text="",
metadata={"thinking_id": "resp_007"},
)
parts = [reasoning]

messages = _build_assistant_pydantic_messages(assistant_msg, parts, timestamp)

assert len(messages) == 0
Loading