diff --git a/src/agentpool_storage/opencode_provider/helpers.py b/src/agentpool_storage/opencode_provider/helpers.py index e748b8e97..0e18dcc8e 100644 --- a/src/agentpool_storage/opencode_provider/helpers.py +++ b/src/agentpool_storage/opencode_provider/helpers.py @@ -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)) elif isinstance(part, ToolPart): tc_part = ToolCallPart( tool_name=part.tool, diff --git a/src/agentpool_storage/opencode_provider/provider.py b/src/agentpool_storage/opencode_provider/provider.py index 9666e3cf4..b4da09dd2 100644 --- a/src/agentpool_storage/opencode_provider/provider.py +++ b/src/agentpool_storage/opencode_provider/provider.py @@ -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" diff --git a/tests/conftest.py b/tests/conftest.py index 14610d22f..464a714e6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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: @@ -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( diff --git a/tests/sessions/test_opencode_thinking_roundtrip.py b/tests/sessions/test_opencode_thinking_roundtrip.py new file mode 100644 index 000000000..4b5dcecf2 --- /dev/null +++ b/tests/sessions/test_opencode_thinking_roundtrip.py @@ -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