feat: preserve Responses reasoning state and compact context (#5172)

This commit is contained in:
chengyongru
2026-07-30 22:39:43 +08:00
committed by GitHub
parent 511c764f45
commit 6a1a45d07a
37 changed files with 4778 additions and 153 deletions
+21 -1
View File
@@ -10,7 +10,11 @@ from nanobot.agent.memory import (
Consolidator,
MemoryStore,
)
from nanobot.providers.base import GenerationSettings, LLMResponse
from nanobot.providers.base import (
GenerationSettings,
LLMResponse,
ProviderConversationState,
)
from nanobot.runtime_context import (
RUNTIME_CONTEXT_HISTORY_META,
RuntimeContextBlock,
@@ -74,6 +78,16 @@ def _tool_round(call_id: str) -> list[dict]:
]
def _provider_state() -> ProviderConversationState:
return ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="test-model",
version=1,
payload={"items": []},
)
class TestConsolidatorSummarize:
async def test_archive_prompt_includes_media_breadcrumb(
self, consolidator, mock_provider, store, runtime
@@ -385,6 +399,7 @@ class TestConsolidatorTokenBudget:
"""Old messages that cannot be replayed should be materialized first."""
consolidator._SAFETY_BUFFER = 0
session = Session(key="test:replay-overflow")
session.provider_state = _provider_state()
for i in range(10):
session.add_message("user", f"u{i}")
session.add_message("assistant", f"a{i}")
@@ -404,6 +419,7 @@ class TestConsolidatorTokenBudget:
assert archived_chunk[-1]["content"] == "a6"
assert session.last_consolidated == 14
assert session.metadata["_last_summary"]["text"] == "old conversation summary"
assert session.provider_state is None
consolidator.sessions.save.assert_called()
async def test_replay_window_overflow_extends_to_long_recent_user_turn(
@@ -479,6 +495,7 @@ class TestConsolidatorTokenBudget:
session = MagicMock()
session.last_consolidated = 0
session.key = "test:key"
session.provider_state = _provider_state()
session.messages = [
{
"role": "user" if i in {0, 50, 61} else "assistant",
@@ -500,6 +517,7 @@ class TestConsolidatorTokenBudget:
# pick_consolidation_boundary returns (50, tokens) — user turn at idx 50
assert archived_chunk[0]["content"] == "m0"
assert session.last_consolidated > 0
assert session.provider_state is None
async def test_raw_archive_fallback_advances_last_consolidated(
self, consolidator, runtime
@@ -610,6 +628,7 @@ class TestCompactIdleSession:
)
sessions = real_consolidator.sessions
session = sessions.get_or_create("cli:test")
session.provider_state = _provider_state()
old_ts = session.updated_at
for i in range(20):
session.add_message("user", f"user msg {i}")
@@ -627,6 +646,7 @@ class TestCompactIdleSession:
assert len(reloaded.messages) == 40
assert reloaded.messages[0]["content"] == "user msg 0"
assert reloaded.last_consolidated == 32
assert reloaded.provider_state is None
visible = reloaded.get_history(max_messages=40)
assert len(visible) == 8
assert visible[0]["content"] == "user msg 16"
+14
View File
@@ -452,6 +452,20 @@ class TestBuildMessages:
assert "previous user message" in str(messages[1]["content"])
assert "new message" in str(messages[1]["content"])
def test_current_message_can_be_built_without_history_merge(self, tmp_path):
builder = _builder(tmp_path)
current = builder.build_current_message(
"new message",
runtime_context_blocks=[
RuntimeContextBlock(source="test", content="fresh context"),
],
)
assert current["role"] == "user"
assert "new message" in current["content"]
assert "fresh context" in current["content"]
assert current["_meta"]["runtime_context"]["sources"] == ["test"]
def test_different_role_appended(self, tmp_path):
builder = _builder(tmp_path)
history = [{"role": "assistant", "content": "previous response"}]
+308 -1
View File
@@ -1,4 +1,5 @@
import asyncio
import json
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
@@ -19,7 +20,7 @@ from nanobot.bus.outbound_events import (
)
from nanobot.bus.queue import MessageBus
from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META
from nanobot.providers.base import LLMResponse
from nanobot.providers.base import LLMProvider, LLMResponse, ProviderConversationState
from nanobot.providers.factory import ProviderSnapshot
from nanobot.runtime_context import (
RUNTIME_CONTEXT_HISTORY_META,
@@ -59,6 +60,16 @@ def _mk_loop() -> AgentLoop:
return loop
def _provider_state() -> ProviderConversationState:
return ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="test-model",
version=1,
payload={"items": []},
)
def _runtime_message(content, blocks: list[RuntimeContextBlock]) -> dict:
merged, marker = append_runtime_context(content, blocks)
assert marker is not None
@@ -494,6 +505,7 @@ def test_restore_runtime_checkpoint_rehydrates_completed_and_pending_tools() ->
loop = _mk_loop()
session = Session(
key="test:checkpoint",
provider_state=_provider_state(),
metadata={
AgentLoop._RUNTIME_CHECKPOINT_KEY: {
"assistant_message": {
@@ -539,6 +551,104 @@ def test_restore_runtime_checkpoint_rehydrates_completed_and_pending_tools() ->
assert session.messages[1]["tool_call_id"] == "call_done"
assert session.messages[2]["tool_call_id"] == "call_pending"
assert "interrupted before this tool finished" in session.messages[2]["content"].lower()
assert session.provider_state is None
def test_restore_final_response_checkpoint_preserves_matching_provider_state() -> None:
loop = _mk_loop()
state = _provider_state()
session = Session(
key="test:final-checkpoint",
provider_state=state,
metadata={
AgentLoop._RUNTIME_CHECKPOINT_KEY: {
"phase": "final_response",
AgentLoop._PROVIDER_STATE_CHECKPOINT_VERSION_KEY: (
AgentLoop._PROVIDER_STATE_CHECKPOINT_VERSION
),
"assistant_message": {
"role": "assistant",
"content": "finished",
},
"completed_tool_results": [],
"pending_tool_calls": [],
}
},
)
restored = loop._restore_runtime_checkpoint(session)
assert restored is True
assert session.messages[-1]["content"] == "finished"
assert session.provider_state is state
assert session.metadata.get(AgentLoop._RUNTIME_CHECKPOINT_KEY) is None
def test_restore_legacy_final_checkpoint_discards_unproven_provider_state() -> None:
loop = _mk_loop()
session = Session(
key="test:legacy-final-checkpoint",
provider_state=_provider_state(),
metadata={
AgentLoop._RUNTIME_CHECKPOINT_KEY: {
"phase": "final_response",
"assistant_message": {
"role": "assistant",
"content": "finished",
},
"completed_tool_results": [],
"pending_tool_calls": [],
}
},
)
restored = loop._restore_runtime_checkpoint(session)
assert restored is True
assert session.messages[-1]["content"] == "finished"
assert session.provider_state is None
def test_restore_completed_tools_checkpoint_preserves_matching_provider_state() -> None:
loop = _mk_loop()
tool_result = {
"role": "tool",
"tool_call_id": "call_done",
"name": "read_file",
"content": "compacted result",
}
state = _provider_state().with_pending_messages([tool_result])
session = Session(
key="test:completed-tools-checkpoint",
provider_state=state,
metadata={
AgentLoop._RUNTIME_CHECKPOINT_KEY: {
"phase": "tools_completed",
AgentLoop._PROVIDER_STATE_CHECKPOINT_VERSION_KEY: (
AgentLoop._PROVIDER_STATE_CHECKPOINT_VERSION
),
"assistant_message": {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_done",
"type": "function",
"function": {"name": "read_file", "arguments": "{}"},
}
],
},
"completed_tool_results": [tool_result],
"pending_tool_calls": [],
}
},
)
restored = loop._restore_runtime_checkpoint(session)
assert restored is True
assert session.messages[-1]["content"] == "compacted result"
assert session.provider_state is state
def test_restore_runtime_checkpoint_dedupes_overlapping_tail() -> None:
@@ -616,6 +726,55 @@ def test_restore_runtime_checkpoint_dedupes_overlapping_tail() -> None:
assert session.messages[2]["tool_call_id"] == "call_pending"
@pytest.mark.asyncio
async def test_runtime_checkpoint_keeps_provider_state_out_of_public_metadata(
tmp_path: Path,
) -> None:
loop = _make_full_loop(tmp_path)
state = ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="test-model",
version=1,
payload={
"items": [
{
"type": "reasoning",
"encrypted_content": "private-checkpoint-blob",
}
]
},
)
loop.provider.can_resume_conversation_state.return_value = True
loop.provider.chat_with_retry = AsyncMock(
return_value=LLMResponse(content="done", provider_state=state)
)
session = loop.sessions.get_or_create("cli:private-checkpoint")
await loop._run_agent_loop(
[
{"role": "system", "content": "system"},
{"role": "user", "content": "question"},
],
runtime=loop.llm_runtime(),
session=session,
)
assert session.provider_state is not None
checkpoint = session.metadata[AgentLoop._RUNTIME_CHECKPOINT_KEY]
assert "provider_state" not in checkpoint
assert checkpoint[AgentLoop._PROVIDER_STATE_CHECKPOINT_VERSION_KEY] == (
AgentLoop._PROVIDER_STATE_CHECKPOINT_VERSION
)
assert "private-checkpoint-blob" not in json.dumps(session.metadata)
public_payload = loop.sessions.read_session_file(session.key)
assert public_payload is not None
assert "private-checkpoint-blob" not in json.dumps(public_payload)
raw = loop.sessions._get_session_path(session.key).read_text(encoding="utf-8")
assert "private-checkpoint-blob" in raw
@pytest.mark.asyncio
async def test_process_message_persists_user_message_before_turn_completes(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
@@ -634,6 +793,150 @@ async def test_process_message_persists_user_message_before_turn_completes(tmp_p
assert persisted.updated_at >= persisted.created_at
@pytest.mark.asyncio
async def test_subagent_followup_stages_provider_state_before_turn_runs(
tmp_path: Path,
) -> None:
loop = _make_full_loop(tmp_path)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
loop._run_agent_loop = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign]
loop.provider.can_resume_conversation_state.return_value = True
session = loop.sessions.get_or_create("cli:subagent-crash")
session.provider_state = _provider_state()
loop.sessions.save(session)
msg = InboundMessage(
channel="system",
sender_id="subagent",
chat_id="cli:subagent-crash",
content="subagent result",
metadata={"subagent_task_id": "sub-1"},
)
with pytest.raises(RuntimeError, match="boom"):
await loop._process_message(msg)
loop.sessions.invalidate("cli:subagent-crash")
persisted = loop.sessions.get_or_create("cli:subagent-crash")
assert persisted.messages[-1]["content"] == "subagent result"
assert persisted.provider_state is not None
assert persisted.provider_state.pending_messages[-1]["role"] == "user"
assert persisted.provider_state.pending_messages[-1]["content"] == "subagent result"
@pytest.mark.asyncio
async def test_subagent_followup_state_is_durable_before_prompt_assembly(
tmp_path: Path,
) -> None:
loop = _make_full_loop(tmp_path)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
loop.provider.can_resume_conversation_state.return_value = True
loop._build_initial_messages = MagicMock( # type: ignore[method-assign]
side_effect=RuntimeError("prompt boom"),
)
session = loop.sessions.get_or_create("cli:subagent-prompt-crash")
session.provider_state = _provider_state()
loop.sessions.save(session)
msg = InboundMessage(
channel="system",
sender_id="subagent",
chat_id="cli:subagent-prompt-crash",
content="subagent result",
metadata={"subagent_task_id": "sub-1"},
)
with pytest.raises(RuntimeError, match="prompt boom"):
await loop._process_message(msg)
loop.sessions.invalidate("cli:subagent-prompt-crash")
persisted = loop.sessions.get_or_create("cli:subagent-prompt-crash")
assert persisted.messages[-1]["content"] == "subagent result"
assert persisted.provider_state is not None
assert persisted.provider_state.pending_messages[-1]["content"] == (
"subagent result"
)
@pytest.mark.asyncio
async def test_subagent_redelivery_does_not_duplicate_staged_provider_input(
tmp_path: Path,
) -> None:
loop = _make_full_loop(tmp_path)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
loop.provider.can_resume_conversation_state.return_value = True
build_initial_messages = loop._build_initial_messages
loop._build_initial_messages = MagicMock( # type: ignore[method-assign]
side_effect=RuntimeError("prompt boom"),
)
session = loop.sessions.get_or_create("cli:subagent-redelivery")
session.provider_state = _provider_state()
loop.sessions.save(session)
msg = InboundMessage(
channel="system",
sender_id="subagent",
chat_id="cli:subagent-redelivery",
content="subagent result",
metadata={"subagent_task_id": "sub-1"},
)
with pytest.raises(RuntimeError, match="prompt boom"):
await loop._process_message(msg)
loop.sessions.invalidate("cli:subagent-redelivery")
persisted = loop.sessions.get_or_create("cli:subagent-redelivery")
assert persisted.provider_state is not None
assert [
message.get("content")
for message in persisted.provider_state.pending_messages
].count("subagent result") == 1
loop._build_initial_messages = build_initial_messages # type: ignore[method-assign]
loop._run_agent_loop = AsyncMock( # type: ignore[method-assign]
side_effect=RuntimeError("provider boom"),
)
with pytest.raises(RuntimeError, match="provider boom"):
await loop._process_message(msg)
provider_state = loop._run_agent_loop.await_args.kwargs["provider_state"]
assert provider_state is not None
pending_results = [
message
for message in provider_state.pending_messages
if message.get("content") == "subagent result"
]
assert len(pending_results) == 1
assert LLMProvider._sanitize_empty_content(pending_results) == [
{"role": "user", "content": "subagent result"},
]
@pytest.mark.asyncio
async def test_subagent_followup_clears_state_before_compatibility_failure(
tmp_path: Path,
) -> None:
loop = _make_full_loop(tmp_path)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
loop.provider.can_resume_conversation_state.side_effect = RuntimeError(
"compatibility boom"
)
session = loop.sessions.get_or_create("cli:subagent-compat-crash")
session.provider_state = _provider_state()
loop.sessions.save(session)
msg = InboundMessage(
channel="system",
sender_id="subagent",
chat_id="cli:subagent-compat-crash",
content="subagent result",
metadata={"subagent_task_id": "sub-1"},
)
with pytest.raises(RuntimeError, match="compatibility boom"):
await loop._process_message(msg)
loop.sessions.invalidate("cli:subagent-compat-crash")
persisted = loop.sessions.get_or_create("cli:subagent-compat-crash")
assert persisted.messages[-1]["content"] == "subagent result"
assert persisted.provider_state is None
@pytest.mark.asyncio
async def test_process_message_persists_unified_session_delivery_route(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
@@ -1245,6 +1548,9 @@ async def test_next_turn_after_crash_closes_pending_user_turn_before_new_input(t
session = loop.sessions.get_or_create("feishu:c3")
session.add_message("user", "old question")
session.metadata[AgentLoop._PENDING_USER_TURN_KEY] = True
session.provider_state = _provider_state().with_pending_messages([
{"role": "user", "content": "old question"},
])
loop.sessions.save(session)
loop._run_agent_loop = AsyncMock(return_value=(
@@ -1278,6 +1584,7 @@ async def test_next_turn_after_crash_closes_pending_user_turn_before_new_input(t
{"role": "assistant", "content": "new answer"},
]
assert AgentLoop._PENDING_USER_TURN_KEY not in session.metadata
assert session.provider_state is None
@pytest.mark.asyncio
+422 -1
View File
@@ -11,7 +11,13 @@ import pytest
from agent.runner_helpers import make_run_spec
from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.providers.base import (
LLMProvider,
LLMResponse,
ProviderCallContext,
ProviderConversationState,
ToolCallRequest,
)
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
@@ -73,6 +79,311 @@ async def test_runner_preserves_reasoning_fields_and_tool_results():
)
@pytest.mark.asyncio
async def test_runner_replays_provider_state_without_chat_projection_duplicates():
from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider)
provider.can_resume_conversation_state.return_value = True
provider.supports_native_compaction.return_value = False
captured_second_kwargs: dict = {}
checkpoints: list[dict] = []
calls = 0
async def checkpoint(payload: dict) -> None:
checkpoints.append(payload)
first_state = ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="gpt-5.6",
version=1,
payload={"items": [{"type": "reasoning", "encrypted_content": "opaque"}]},
)
second_state = ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="gpt-5.6",
version=1,
payload={"items": [{"type": "message", "role": "assistant"}]},
)
async def chat_with_retry(**kwargs):
nonlocal calls
calls += 1
if calls == 1:
provider_context = kwargs["provider_context"]
assert isinstance(provider_context, ProviderCallContext)
assert provider_context.conversation_state is None
return LLMResponse(
content=None,
tool_calls=[
ToolCallRequest(
id="call_1|fc_1",
name="list_dir",
arguments={"path": "."},
),
],
provider_state=first_state,
)
captured_second_kwargs.update(kwargs)
return LLMResponse(content="done", provider_state=second_state)
provider.chat_with_retry = chat_with_retry
tools = MagicMock()
tools.get_definitions.return_value = []
tools.execute = AsyncMock(return_value="tool result")
result = await AgentRunner().run(make_run_spec(
provider,
initial_messages=[
{"role": "system", "content": "system"},
{"role": "user", "content": "do task"},
],
tools=tools,
model="gpt-5.6",
max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
checkpoint_callback=checkpoint,
))
provider_context = captured_second_kwargs["provider_context"]
assert isinstance(provider_context, ProviderCallContext)
assert provider_context.conversation_state is not None
assert provider_context.conversation_state.payload == first_state.payload
assert provider_context.conversation_state.pending_messages == [{
"role": "tool",
"tool_call_id": "call_1|fc_1",
"name": "list_dir",
"content": "tool result",
}]
assert not any(
message.get("role") == "assistant"
for message in provider_context.conversation_state.pending_messages
)
assert result.provider_state is not None
assert result.provider_state.payload == second_state.payload
assert result.provider_state.pending_messages == []
assert checkpoints[0]["phase"] == "awaiting_tools"
assert "provider_state" not in checkpoints[0]
assert checkpoints[1]["phase"] == "tools_completed"
assert checkpoints[1]["provider_state"].pending_messages == [{
"role": "tool",
"tool_call_id": "call_1|fc_1",
"name": "list_dir",
"content": "tool result",
}]
assert checkpoints[2]["phase"] == "final_response"
assert checkpoints[2]["provider_state"].payload == second_state.payload
@pytest.mark.asyncio
async def test_runner_governs_tool_result_before_adding_it_to_provider_state():
from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider)
provider.can_resume_conversation_state.return_value = True
provider.supports_native_compaction.return_value = False
calls = 0
captured_context: ProviderCallContext | None = None
checkpoints: list[dict] = []
state = ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="gpt-5.6",
version=1,
payload={"items": [{"type": "reasoning", "encrypted_content": "opaque"}]},
)
async def chat_with_retry(**kwargs):
nonlocal calls, captured_context
calls += 1
if calls == 1:
return LLMResponse(
content=None,
tool_calls=[
ToolCallRequest(
id="call_1",
name="read_file",
arguments={"path": "large.txt"},
),
],
provider_state=state,
)
captured_context = kwargs["provider_context"]
return LLMResponse(content="done")
provider.chat_with_retry = chat_with_retry
tools = MagicMock()
tools.get_definitions.return_value = []
tools.execute = AsyncMock(return_value="x" * 5_000)
async def checkpoint(payload: dict) -> None:
checkpoints.append(payload)
await AgentRunner().run(make_run_spec(
provider,
initial_messages=[
{"role": "system", "content": "system"},
{"role": "user", "content": "read the file"},
],
tools=tools,
model="gpt-5.6",
context_window_tokens=3_000,
context_block_limit=200,
max_tokens=1_000,
max_iterations=3,
max_tool_result_chars=10_000,
checkpoint_callback=checkpoint,
))
assert captured_context is not None
assert captured_context.conversation_state is not None
pending = captured_context.conversation_state.pending_messages
assert len(pending) == 1
assert pending[0]["role"] == "tool"
assert "compacted to fit context" in pending[0]["content"]
assert pending[0]["content"] != "x" * 5_000
completed_checkpoint = next(
checkpoint
for checkpoint in checkpoints
if checkpoint["phase"] == "tools_completed"
)
checkpoint_pending = completed_checkpoint["provider_state"].pending_messages
assert "compacted to fit context" in checkpoint_pending[0]["content"]
assert checkpoint_pending[0]["content"] != "x" * 5_000
@pytest.mark.asyncio
async def test_injected_final_response_checkpoint_includes_provider_state():
from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider)
provider.can_resume_conversation_state.return_value = True
provider.supports_native_compaction.return_value = False
first_state = ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="gpt-5.6",
version=1,
payload={"items": [{"type": "message", "content": "first answer"}]},
)
second_state = ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="gpt-5.6",
version=1,
payload={"items": [{"type": "message", "content": "second answer"}]},
)
provider.chat_with_retry = AsyncMock(side_effect=[
LLMResponse(content="first answer", provider_state=first_state),
LLMResponse(content="second answer", provider_state=second_state),
])
tools = MagicMock()
tools.get_definitions.return_value = []
checkpoints: list[dict] = []
injections = [[{"role": "user", "content": "follow up"}], []]
async def checkpoint(payload: dict) -> None:
checkpoints.append(payload)
async def inject() -> list[dict]:
return injections.pop(0)
await AgentRunner().run(make_run_spec(
provider,
initial_messages=[{"role": "user", "content": "start"}],
tools=tools,
model="gpt-5.6",
max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
checkpoint_callback=checkpoint,
injection_callback=inject,
))
assert checkpoints[0]["phase"] == "final_response"
assert checkpoints[0]["provider_state"].payload == first_state.payload
@pytest.mark.asyncio
async def test_runner_preserves_last_completed_provider_state_on_model_error():
from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider)
provider.can_resume_conversation_state.return_value = True
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
content="temporary upstream failure",
finish_reason="error",
error_kind="timeout",
))
tools = MagicMock()
tools.get_definitions.return_value = []
state = ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="gpt-5.6",
version=1,
payload={"items": [{"type": "reasoning", "encrypted_content": "opaque"}]},
)
unsaved_input = {"role": "user", "content": "ephemeral follow-up"}
result = await AgentRunner().run(make_run_spec(
provider,
initial_messages=[
{"role": "system", "content": "system"},
unsaved_input,
],
tools=tools,
model="gpt-5.6",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
provider_state=state.with_pending_messages([unsaved_input]),
))
assert result.stop_reason == "error"
assert result.provider_state is not None
assert result.provider_state.payload == state.payload
assert result.provider_state.pending_messages[0] == unsaved_input
assert result.provider_state.pending_messages[1]["role"] == "assistant"
assert "model error" in result.provider_state.pending_messages[1]["content"]
@pytest.mark.asyncio
async def test_runner_discards_provider_state_on_non_retryable_model_error():
from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider)
provider.can_resume_conversation_state.return_value = True
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
content="context length exceeded",
finish_reason="error",
error_status_code=400,
error_should_retry=False,
))
tools = MagicMock()
tools.get_definitions.return_value = []
state = ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="gpt-5.6",
version=1,
payload={"items": [{"type": "reasoning", "encrypted_content": "opaque"}]},
)
result = await AgentRunner().run(make_run_spec(
provider,
initial_messages=[{"role": "user", "content": "continue"}],
tools=tools,
model="gpt-5.6",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
provider_state=state,
))
assert result.stop_reason == "error"
assert result.provider_state is None
@pytest.mark.asyncio
async def test_runner_returns_max_iterations_fallback():
from nanobot.agent.runner import AgentRunner
@@ -422,6 +733,66 @@ async def test_runner_retries_empty_final_response_with_summary_prompt():
assert result.usage["completion_tokens"] == 9
@pytest.mark.asyncio
@pytest.mark.parametrize("finish_reason", ["refusal", "content_filter"])
async def test_runner_does_not_retry_blank_policy_terminal(
finish_reason: str,
) -> None:
from nanobot.agent.runner import AgentRunner
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
provider = MagicMock(spec=LLMProvider)
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
content=None,
finish_reason=finish_reason,
))
tools = MagicMock()
tools.get_definitions.return_value = []
result = await AgentRunner().run(make_run_spec(
provider,
initial_messages=[{"role": "user", "content": "do task"}],
tools=tools,
model="test-model",
max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
))
assert provider.chat_with_retry.await_count == 1
assert result.final_content == EMPTY_FINAL_RESPONSE_MESSAGE
assert result.stop_reason == "empty_final_response"
@pytest.mark.asyncio
@pytest.mark.parametrize("finish_reason", ["refusal", "content_filter"])
async def test_runner_does_not_auto_continue_goal_after_policy_terminal(
finish_reason: str,
) -> None:
from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider)
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
content="Request blocked by provider policy.",
finish_reason=finish_reason,
))
tools = MagicMock()
tools.get_definitions.return_value = []
result = await AgentRunner().run(make_run_spec(
provider,
initial_messages=[{"role": "user", "content": "do task"}],
tools=tools,
model="test-model",
max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
goal_active_predicate=lambda: True,
))
assert provider.chat_with_retry.await_count == 1
assert result.final_content == "Request blocked by provider policy."
assert result.stop_reason == "completed"
@pytest.mark.asyncio
async def test_runner_uses_specific_message_after_empty_finalization_retry():
"""After silent retries + finalization all return empty, stop_reason is empty_final_response."""
@@ -450,6 +821,56 @@ async def test_runner_uses_specific_message_after_empty_finalization_retry():
assert result.stop_reason == "empty_final_response"
@pytest.mark.asyncio
async def test_empty_finalization_retry_discards_candidate_provider_state():
from nanobot.agent.runner import AgentRunner
candidate = ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="test-model",
version=1,
payload={
"items": [{
"type": "function_call",
"call_id": "call_1",
"name": "exec",
"arguments": "{}",
}],
},
)
provider = MagicMock(spec=LLMProvider)
provider.can_resume_conversation_state.return_value = True
provider.chat_with_retry = AsyncMock(side_effect=[
LLMResponse(content=None, tool_calls=[], usage={}),
LLMResponse(content=None, tool_calls=[], usage={}),
LLMResponse(
content="finalized without tools",
tool_calls=[ToolCallRequest(id="call_1", name="exec", arguments={})],
finish_reason="stop",
provider_state=candidate,
usage={},
),
])
tools = MagicMock()
tools.get_definitions.return_value = []
tools.execute = AsyncMock(return_value="must not run")
runner = AgentRunner()
result = await runner.run(make_run_spec(
provider,
initial_messages=[{"role": "user", "content": "do task"}],
tools=tools,
model="test-model",
max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
))
tools.execute.assert_not_awaited()
assert result.final_content == "finalized without tools"
assert result.provider_state is None
@pytest.mark.asyncio
async def test_runner_length_recovery_returns_all_segments():
"""Recovered output segments are returned together instead of only the tail."""
+284 -1
View File
@@ -9,8 +9,15 @@ import pytest
from loguru import logger
from nanobot.config.schema import ModelPresetConfig
from nanobot.providers.base import LLMProvider, LLMResponse
from nanobot.providers.base import (
LLMProvider,
LLMResponse,
ProviderCallContext,
ProviderConversationState,
)
from nanobot.providers.conversation_state import ProviderConversationStateController
from nanobot.providers.fallback_provider import FallbackProvider
from nanobot.providers.openai_responses import resolve_compact_threshold
def _make_response(
@@ -66,6 +73,9 @@ class _FakeProvider(LLMProvider):
self._response = response or _make_response()
self.chat_calls: list[dict[str, Any]] = []
self.chat_stream_calls: list[dict[str, Any]] = []
self.context_calls: list[ProviderCallContext | None] = []
self.resumable = False
self.compact = False
def get_default_model(self) -> str:
return f"{self.name}/model"
@@ -81,6 +91,26 @@ class _FakeProvider(LLMProvider):
await on_delta(self._response.content)
return self._response
async def chat_with_context(
self,
provider_context: ProviderCallContext | None = None,
**kwargs: Any,
) -> LLMResponse:
self.context_calls.append(provider_context)
return await self.chat(**kwargs)
def can_resume_conversation_state(
self,
state: ProviderConversationState,
model: str | None = None,
) -> bool:
_ = state, model
return self.resumable
def supports_native_compaction(self, model: str | None = None) -> bool:
_ = model
return self.compact
# -- config-level tests --
@@ -211,6 +241,8 @@ def test_provider_snapshot_uses_smallest_fallback_context_window() -> None:
snapshot = build_provider_snapshot(config)
assert snapshot.context_window_tokens == 64000
assert isinstance(snapshot.provider, FallbackProvider)
assert snapshot.provider._primary_context_window_tokens == 128000
def test_inline_fallback_reasoning_effort_does_not_inherit_primary() -> None:
@@ -285,6 +317,257 @@ class TestFallbackOnPrimaryError:
assert primary.chat_calls[0]["model"] == "primary-model"
assert fallback.chat_calls[0]["model"] == "fallback-a"
@pytest.mark.asyncio
async def test_primary_compaction_uses_primary_context_window(self) -> None:
primary = _FakeProvider("primary", _make_response("primary ok"))
primary.compact = True
fb = FallbackProvider(
primary=primary,
fallback_presets=[
_fallback("small-chat", context_window_tokens=50_000),
],
provider_factory=MagicMock(),
primary_context_window_tokens=200_000,
)
await fb.chat_with_context(
messages=[{"role": "user", "content": "hi"}],
model="gpt-5.6",
max_tokens=10_000,
provider_context=ProviderCallContext(context_window_tokens=50_000),
)
primary_context = primary.context_calls[0]
assert primary_context is not None
assert primary_context.context_window_tokens == 200_000
assert resolve_compact_threshold(
primary_context.context_window_tokens,
10_000,
) == 180_000
@pytest.mark.asyncio
async def test_native_fallback_compaction_uses_its_own_context_window(self) -> None:
primary = _FakeProvider("primary", _error_response())
primary.compact = True
fallback = _FakeProvider("fallback", _make_response("fallback ok"))
fallback.compact = True
fb = FallbackProvider(
primary=primary,
fallback_presets=[
_fallback("fallback-a", context_window_tokens=120_000),
],
provider_factory=MagicMock(return_value=fallback),
primary_context_window_tokens=200_000,
)
result = await fb.chat_with_context(
messages=[{"role": "user", "content": "hi"}],
model="gpt-5.6",
provider_context=ProviderCallContext(context_window_tokens=50_000),
)
assert result.content == "fallback ok"
assert primary.context_calls == [
ProviderCallContext(context_window_tokens=200_000)
]
assert fallback.context_calls == [
ProviderCallContext(context_window_tokens=120_000)
]
@pytest.mark.asyncio
async def test_native_fallback_gets_context_when_primary_does_not_use_it(self) -> None:
primary = _FakeProvider("primary", _error_response())
fallback = _FakeProvider("fallback", _make_response("fallback ok"))
fallback.compact = True
fb = FallbackProvider(
primary=primary,
fallback_presets=[
_fallback("fallback-a", context_window_tokens=120_000),
],
provider_factory=MagicMock(return_value=fallback),
primary_context_window_tokens=200_000,
)
messages = [{"role": "user", "content": "hi"}]
controller = ProviderConversationStateController(
provider=fb,
model="primary-model",
messages=messages,
)
assert fb.supports_native_compaction("primary-model") is False
provider_context = controller.prepare_request(
messages,
context_window_tokens=50_000,
)
assert provider_context == ProviderCallContext(
context_window_tokens=50_000
)
result = await fb.chat_with_context(
messages=messages,
model="primary-model",
provider_context=provider_context,
)
assert result.content == "fallback ok"
assert primary.context_calls == [ProviderCallContext()]
assert fallback.context_calls == [
ProviderCallContext(context_window_tokens=120_000)
]
@pytest.mark.asyncio
async def test_responses_chat_fallback_responses_rebuilds_state(self) -> None:
primary = _FakeProvider("primary", _error_response())
primary.resumable = True
primary.compact = True
fallback = _FakeProvider("fallback", _make_response("fallback ok"))
messages = [{"role": "user", "content": "hi"}]
state = ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="gpt-5.6",
version=1,
payload={"items": [{"type": "reasoning", "encrypted_content": "opaque"}]},
pending_messages=list(messages),
)
fb = FallbackProvider(
primary=primary,
fallback_presets=[_fallback("fallback-a")],
provider_factory=MagicMock(return_value=fallback),
)
controller = ProviderConversationStateController(
provider=fb,
model="gpt-5.6",
messages=messages,
state=state,
)
provider_context = controller.prepare_request(
messages,
context_window_tokens=200_000,
)
assert provider_context is not None
result = await fb.chat_with_context(
messages=messages,
model="gpt-5.6",
provider_context=provider_context,
)
assert result.content == "fallback ok"
assert primary.context_calls == [provider_context]
assert fallback.context_calls == [ProviderCallContext()]
assert fallback.chat_calls[0]["messages"] == messages
controller.observe_response(result, messages)
messages.append({"role": "assistant", "content": result.content})
assert controller.finish(messages) is None
recovered_state = ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="gpt-5.6",
version=1,
payload={"items": [{"type": "reasoning", "encrypted_content": "recovered"}]},
)
primary._response = LLMResponse(
content="primary recovered",
provider_state=recovered_state,
)
next_turn = ProviderConversationStateController(
provider=fb,
model="gpt-5.6",
messages=messages,
)
next_context = next_turn.prepare_request(
messages,
context_window_tokens=200_000,
)
assert next_context == ProviderCallContext(context_window_tokens=200_000)
recovered = await fb.chat_with_context(
messages=messages,
model="gpt-5.6",
provider_context=next_context,
)
assert recovered.provider_state is recovered_state
assert primary.context_calls[-1] == next_context
assert primary.chat_calls[-1]["messages"] == messages
@pytest.mark.asyncio
@pytest.mark.parametrize(
("primary_error_kind", "primary_status", "primary_should_retry"),
[
("server_error", 503, True),
("authentication", 401, False),
],
ids=["transient", "authentication"],
)
async def test_final_fallback_error_uses_primary_state_disposition(
self,
primary_error_kind: str,
primary_status: int,
primary_should_retry: bool,
) -> None:
primary = _FakeProvider(
"primary",
_make_response(
"primary unavailable",
finish_reason="error",
error_kind=primary_error_kind,
error_status_code=primary_status,
error_should_retry=primary_should_retry,
),
)
primary.resumable = True
fallback = _FakeProvider(
"fallback",
_make_response(
"fallback invalid request",
finish_reason="error",
error_kind="invalid_request",
error_status_code=400,
error_should_retry=False,
),
)
messages = [{"role": "user", "content": "continue"}]
state = ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="gpt-5.6",
version=1,
payload={"items": [{"type": "reasoning", "encrypted_content": "opaque"}]},
pending_messages=list(messages),
)
provider = FallbackProvider(
primary=primary,
fallback_presets=[_fallback("fallback-a")],
provider_factory=MagicMock(return_value=fallback),
)
controller = ProviderConversationStateController(
provider=provider,
model="gpt-5.6",
messages=messages,
state=state,
)
provider_context = controller.prepare_request(
messages,
context_window_tokens=200_000,
)
assert provider_context is not None
response = await provider.chat_with_context(
messages=messages,
model="gpt-5.6",
provider_context=provider_context,
)
controller.observe_response(response, messages)
assert response.content == "fallback invalid request"
assert response.preserve_provider_state_on_error is True
restored = controller.finish(messages)
assert restored is not None
assert restored.payload == state.payload
@pytest.mark.asyncio
async def test_reports_the_fallback_model_before_its_request(self) -> None:
primary = _FakeProvider("primary", _error_response())
+14 -1
View File
@@ -15,7 +15,11 @@ from nanobot.agent.context_governance import (
)
from nanobot.agent.runner import AgentRunSpec
from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest
from nanobot.providers.base import (
LLMResponse,
ProviderConversationState,
ToolCallRequest,
)
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
@@ -886,6 +890,13 @@ def test_drop_malformed_tool_calls_trims_response():
"""LLM response tool_calls with a missing/empty name are dropped in place."""
from nanobot.agent.runner import AgentRunner
candidate_state = ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="gpt-5.6",
version=1,
payload={"items": [{"type": "function_call", "name": None}]},
)
response = LLMResponse(
content=None,
tool_calls=[
@@ -895,9 +906,11 @@ def test_drop_malformed_tool_calls_trims_response():
ToolCallRequest(id="4", name="read_file", arguments={}),
],
finish_reason="tool_calls",
provider_state=candidate_state,
)
dropped, all_dropped, orig = AgentRunner._drop_malformed_tool_calls(response)
assert [tc.name for tc in response.tool_calls] == ["read_file"]
assert response.provider_state is None
assert response.finish_reason == "tool_calls"
assert response.should_execute_tools is True
assert dropped == 3
+132
View File
@@ -4,6 +4,7 @@ import json
from datetime import datetime
from pathlib import Path
from nanobot.providers.base import ProviderConversationState
from nanobot.session.manager import Session, SessionManager
@@ -101,6 +102,137 @@ class TestAtomicSave:
for i in range(5):
assert loaded.messages[i]["content"] == f"msg{i}"
def test_provider_state_round_trips_in_private_record_only(self, tmp_path: Path):
mgr = SessionManager(tmp_path)
secret = "encrypted-reasoning-blob"
session = Session(
key="test:provider-state",
provider_state=ProviderConversationState(
kind="openai_responses",
provider="openai:https://api.openai.com/v1",
model="gpt-5.6",
version=1,
payload={
"items": [
{
"type": "reasoning",
"encrypted_content": secret,
}
]
},
pending_messages=[{"role": "user", "content": "continue"}],
),
)
session.add_message("user", "hello")
mgr.save(session)
records = [
json.loads(line)
for line in mgr._get_session_path(session.key)
.read_text(encoding="utf-8")
.splitlines()
]
assert [record.get("_type") for record in records] == [
"metadata",
"provider_state",
None,
]
assert secret in records[1]["state"]["payload"]["items"][0]["encrypted_content"]
mgr.invalidate(session.key)
loaded = mgr.get_or_create(session.key)
assert loaded.provider_state is not None
assert loaded.provider_state.to_private_record() == session.provider_state.to_private_record()
public_payload = mgr.read_session_file(session.key)
assert public_payload is not None
assert public_payload["messages"] == [session.messages[0]]
assert secret not in json.dumps(public_payload)
assert secret not in json.dumps(mgr.list_sessions())
def test_provider_state_does_not_consume_list_preview_budget(
self,
tmp_path: Path,
monkeypatch,
):
import nanobot.session.manager as session_manager
monkeypatch.setattr(session_manager, "_SESSION_LIST_PREVIEW_MAX_CHARS", 100)
mgr = SessionManager(tmp_path)
session = Session(
key="test:provider-state-preview",
provider_state=ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="test-model",
version=1,
payload={"items": [{"encrypted_content": "x" * 200}]},
),
)
session.add_message("user", "visible preview")
mgr.save(session)
assert mgr.list_sessions()[0]["preview"] == "visible preview"
def test_clear_and_fork_discard_provider_state(self, tmp_path: Path):
mgr = SessionManager(tmp_path)
state = ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="gpt-5.6",
version=1,
payload={"items": []},
)
source = Session(key="test:state-source", provider_state=state)
source.add_message("user", "hello")
mgr.save(source)
fork = mgr.fork_session_before_user_index(
source.key,
"test:state-fork",
1,
)
assert fork is not None
assert fork.provider_state is None
source.clear()
assert source.provider_state is None
def test_invalid_provider_state_record_is_not_public_history(self, tmp_path: Path):
mgr = SessionManager(tmp_path)
path = mgr._get_session_path("test:bad-provider-state")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
"\n".join(
[
json.dumps(
{
"_type": "metadata",
"key": "test:bad-provider-state",
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
"metadata": {},
"last_consolidated": 0,
}
),
json.dumps(
{
"_type": "provider_state",
"state": {"kind": "openai_responses"},
}
),
json.dumps({"role": "user", "content": "safe"}),
]
)
+ "\n",
encoding="utf-8",
)
loaded = mgr._load("test:bad-provider-state")
assert loaded is not None
assert loaded.provider_state is None
assert loaded.messages == [{"role": "user", "content": "safe"}]
class TestRepairCorruptFile:
def _write_corrupt_jsonl(self, path: Path, lines: list[str]) -> None:
+21 -2
View File
@@ -1,3 +1,4 @@
from nanobot.providers.base import ProviderConversationState
from nanobot.runtime_context import (
RUNTIME_CONTEXT_HISTORY_META,
RuntimeContextBlock,
@@ -769,7 +770,16 @@ def test_get_history_extend_to_user_keeps_newer_user_inside_window():
def test_retain_recent_legal_suffix_returns_dropped_messages():
"""retain_recent_legal_suffix returns the actually-dropped messages."""
session = Session(key="test:return-dropped")
session = Session(
key="test:return-dropped",
provider_state=ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="test-model",
version=1,
payload={"items": []},
),
)
for i in range(10):
session.messages.append({"role": "user", "content": f"msg{i}"})
@@ -779,11 +789,19 @@ def test_retain_recent_legal_suffix_returns_dropped_messages():
assert [m["content"] for m in result.dropped] == [f"msg{i}" for i in range(6)]
assert len(session.messages) == 4
assert result.already_consolidated_count == 0
assert session.provider_state is None
def test_retain_recent_legal_suffix_returns_empty_when_no_drop():
"""No messages dropped → empty list returned."""
session = Session(key="test:no-drop")
state = ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="test-model",
version=1,
payload={"items": []},
)
session = Session(key="test:no-drop", provider_state=state)
for i in range(3):
session.messages.append({"role": "user", "content": f"msg{i}"})
@@ -792,6 +810,7 @@ def test_retain_recent_legal_suffix_returns_empty_when_no_drop():
assert result.dropped == []
assert result.already_consolidated_count == 0
assert len(session.messages) == 3
assert session.provider_state is state
def test_retain_recent_legal_suffix_returns_all_on_zero():
+3
View File
@@ -504,6 +504,7 @@ async def test_drain_pending_blocks_while_subagents_running(tmp_path):
usage={},
had_injections=False,
tools_used=[],
provider_state=None,
)
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
@@ -589,6 +590,7 @@ async def test_drain_pending_no_block_when_no_subagents(tmp_path):
usage={},
had_injections=False,
tools_used=[],
provider_state=None,
)
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
@@ -638,6 +640,7 @@ async def test_drain_pending_timeout(tmp_path):
usage={},
had_injections=False,
tools_used=[],
provider_state=None,
)
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
+61 -1
View File
@@ -11,7 +11,7 @@ from nanobot.providers.azure_openai_provider import (
AzureOpenAIProvider,
_AzureTokenProvider,
)
from nanobot.providers.base import LLMResponse
from nanobot.providers.base import LLMResponse, ProviderCallContext
# ---------------------------------------------------------------------------
# Init & validation
@@ -234,6 +234,7 @@ def test_build_body_basic():
assert body["max_output_tokens"] == 4096
assert body["store"] is False
assert "reasoning" not in body
assert "include" not in body
# input should contain the converted user message only (system extracted)
assert any(
item.get("role") == "user"
@@ -241,6 +242,30 @@ def test_build_body_basic():
)
def test_build_body_enables_server_compaction():
provider = AzureOpenAIProvider(
api_key="k",
api_base="https://res.openai.azure.com",
default_model="gpt-5.6",
)
body = provider._build_body(
[{"role": "user", "content": "hello"}],
None,
None,
10_000,
0.1,
"high",
None,
provider_context=ProviderCallContext(context_window_tokens=200_000),
)
assert body["context_management"] == [{
"type": "compaction",
"compact_threshold": 180_000,
}]
def test_build_body_max_tokens_minimum():
"""max_output_tokens should never be less than 1."""
provider = AzureOpenAIProvider(api_key="k", api_base="https://r.com", default_model="gpt-4o")
@@ -358,6 +383,38 @@ async def test_chat_success():
assert result.usage["prompt_tokens"] == 10
@pytest.mark.asyncio
async def test_chat_retries_without_unsupported_server_compaction():
provider = AzureOpenAIProvider(
api_key="test-key",
api_base="https://test.openai.azure.com",
default_model="gpt-5.6",
)
class UnsupportedCompactionError(Exception):
status_code = 400
body = {"error": {"message": "Unknown parameter: context_management"}}
provider._client.responses = MagicMock()
provider._client.responses.create = AsyncMock(side_effect=[
UnsupportedCompactionError(),
_make_sdk_response(content="compaction fallback"),
])
result = await provider.chat(
[{"role": "user", "content": "Hi"}],
provider_context=ProviderCallContext(context_window_tokens=200_000),
)
create = provider._client.responses.create
assert result.content == "compaction fallback"
assert result.provider_state is not None
assert create.await_count == 2
assert "context_management" in create.call_args_list[0].kwargs
assert "context_management" not in create.call_args_list[1].kwargs
assert provider.supports_native_compaction() is False
@pytest.mark.asyncio
async def test_chat_uses_default_model():
provider = AzureOpenAIProvider(
@@ -411,6 +468,7 @@ async def test_chat_with_tool_calls():
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "get_weather"
assert result.tool_calls[0].arguments == {"location": "SF"}
assert result.provider_state is not None
@pytest.mark.asyncio
@@ -510,6 +568,7 @@ async def test_chat_stream_with_tool_calls():
item_done.name = "get_weather"
ev_item_done = MagicMock(type="response.output_item.done", item=item_done)
resp_obj = MagicMock(status="completed")
resp_obj.model_dump.return_value = {"status": "completed", "output": []}
ev_completed = MagicMock(type="response.completed", response=resp_obj)
async def mock_stream():
@@ -527,6 +586,7 @@ async def test_chat_stream_with_tool_calls():
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "get_weather"
assert result.tool_calls[0].arguments == {"location": "SF"}
assert result.provider_state is not None
@pytest.mark.asyncio
+291
View File
@@ -0,0 +1,291 @@
"""Tests for provider-owned conversation-state lifecycle coordination."""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from nanobot.providers.base import (
LLMProvider,
LLMResponse,
ProviderConversationState,
ToolCallRequest,
)
from nanobot.providers.conversation_state import (
ProviderConversationStateController,
allows_conversation_message_merge,
)
def _provider(*, resumable: bool = True, compact: bool = False) -> MagicMock:
provider = MagicMock(spec=LLMProvider)
provider.can_resume_conversation_state.return_value = resumable
provider.supports_native_compaction.return_value = compact
return provider
def _state(label: str, *, pending: list[dict] | None = None) -> ProviderConversationState:
return ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="gpt-5.6",
version=1,
payload={"items": [{"type": "reasoning", "encrypted_content": label}]},
pending_messages=pending or [],
)
def test_controller_replays_only_messages_after_provider_output() -> None:
provider = _provider()
messages = [
{"role": "system", "content": "system"},
{"role": "user", "content": "run a tool"},
]
controller = ProviderConversationStateController(
provider=provider,
model="gpt-5.6",
messages=messages,
)
state = _state("first")
controller.prepare_request(messages, context_window_tokens=200_000)
response = LLMResponse(content=None, provider_state=state)
controller.observe_response(response, messages)
assert allows_conversation_message_merge(messages[-1]) is False
messages.append(controller.project_response_message(
{
"role": "assistant",
"content": None,
"tool_calls": [{"id": "call_1", "type": "function"}],
},
response,
))
tool_message = {
"role": "tool",
"tool_call_id": "call_1",
"content": "tool result",
}
messages.append(tool_message)
provider_context = controller.prepare_request(
messages,
context_window_tokens=200_000,
)
assert provider_context is not None
assert provider_context.conversation_state is not None
assert provider_context.conversation_state.payload == state.payload
assert provider_context.conversation_state.pending_messages == [tool_message]
assert controller.checkpoint(messages).pending_messages == [tool_message]
def test_controller_uses_governed_messages_for_provider_state_delta() -> None:
provider = _provider()
messages = [
{"role": "user", "content": "run a tool"},
]
controller = ProviderConversationStateController(
provider=provider,
model="gpt-5.6",
messages=messages,
)
state = _state("first")
controller.prepare_request(messages, context_window_tokens=200_000)
response = LLMResponse(content=None, provider_state=state)
controller.observe_response(response, messages)
messages.extend([
controller.project_response_message(
{
"role": "assistant",
"content": None,
"tool_calls": [{"id": "call_1", "type": "function"}],
},
response,
),
{
"role": "tool",
"tool_call_id": "call_1",
"content": "raw oversized result",
},
])
governed_messages = [
messages[0],
messages[1],
{
"role": "tool",
"tool_call_id": "call_1",
"content": "compacted result",
},
]
provider_context = controller.prepare_request(
messages,
context_window_tokens=200_000,
model_messages=governed_messages,
)
assert provider_context is not None
assert provider_context.conversation_state is not None
assert provider_context.conversation_state.pending_messages == [{
"role": "tool",
"tool_call_id": "call_1",
"content": "compacted result",
}]
assert controller.checkpoint(messages).pending_messages[-1]["content"] == (
"raw oversized result"
)
governed_checkpoint = controller.checkpoint(
messages,
model_messages=governed_messages,
)
assert governed_checkpoint is not None
assert governed_checkpoint.pending_messages[-1]["content"] == "compacted result"
def test_transient_response_preserves_only_durable_request_messages() -> None:
provider = _provider()
current_message = {"role": "user", "content": "continue"}
supplemental = {"role": "user", "content": "internal finalization retry"}
messages = [{"role": "system", "content": "system"}, current_message]
controller = ProviderConversationStateController(
provider=provider,
model="gpt-5.6",
messages=messages,
state=_state("saved", pending=[
{"role": "tool", "content": "prior"},
current_message,
]),
)
provider_context = controller.prepare_request(
messages,
context_window_tokens=200_000,
supplemental_messages=[supplemental],
)
assert provider_context is not None
assert provider_context.conversation_state is not None
assert provider_context.conversation_state.pending_messages == [
{"role": "tool", "content": "prior"},
current_message,
supplemental,
]
controller.observe_response(
LLMResponse(
content="temporary failure",
finish_reason="error",
error_kind="timeout",
),
messages,
)
placeholder = {"role": "assistant", "content": "model error"}
messages.append(placeholder)
state = controller.finish(messages)
assert state is not None
assert state.pending_messages == [
{"role": "tool", "content": "prior"},
current_message,
placeholder,
]
def test_non_retryable_response_discards_saved_state() -> None:
provider = _provider()
messages = [{"role": "user", "content": "continue"}]
controller = ProviderConversationStateController(
provider=provider,
model="gpt-5.6",
messages=messages,
state=_state("saved"),
)
controller.prepare_request(messages, context_window_tokens=200_000)
controller.observe_response(
LLMResponse(
content="invalid request",
finish_reason="error",
error_status_code=400,
error_should_retry=False,
),
messages,
)
assert controller.finish(messages) is None
@pytest.mark.parametrize(
("finish_reason", "exposes_tool_call"),
[
("length", False),
("length", True),
("refusal", True),
("content_filter", True),
],
)
def test_terminal_response_discards_candidate_state(
finish_reason: str,
exposes_tool_call: bool,
) -> None:
provider = _provider()
messages = [{"role": "user", "content": "continue"}]
controller = ProviderConversationStateController(
provider=provider,
model="gpt-5.6",
messages=messages,
state=_state("saved"),
)
controller.prepare_request(messages, context_window_tokens=200_000)
candidate = ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="gpt-5.6",
version=1,
payload={
"items": [{
"type": "function_call",
"call_id": "call_1",
"name": "exec",
"arguments": "{}",
}],
},
)
response = LLMResponse(
content="terminal response",
tool_calls=(
[ToolCallRequest(id="call_1", name="exec", arguments={})]
if exposes_tool_call
else []
),
finish_reason=finish_reason,
provider_state=candidate,
)
assert response.has_tool_calls is exposes_tool_call
assert response.should_execute_tools is False
controller.observe_response(response, messages)
assert controller.finish(messages) is None
def test_independent_request_exposes_context_without_capability_check() -> None:
provider = _provider(compact=False)
messages = [{"role": "user", "content": "hello"}]
controller = ProviderConversationStateController(
provider=provider,
model="gpt-5.6",
messages=messages,
state=_state("saved"),
)
provider_context = controller.independent_request_context(
context_window_tokens=200_000,
)
assert provider_context is not None
assert provider_context.conversation_state is None
assert provider_context.context_window_tokens == 200_000
provider.supports_native_compaction.assert_not_called()
@@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from nanobot.providers.base import ProviderCallContext
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
from nanobot.providers.registry import find_by_name
@@ -44,8 +45,10 @@ def test_build_responses_body_strips_github_copilot_prefix():
temperature=0.1,
reasoning_effort=None,
tool_choice=None,
provider_context=ProviderCallContext(context_window_tokens=128_000),
)
assert body["model"] == "gpt-5.4-mini"
assert "context_management" not in body
@pytest.mark.asyncio
+36
View File
@@ -14,6 +14,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from nanobot.providers.base import ProviderCallContext
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
from nanobot.providers.registry import find_by_name
@@ -679,6 +680,7 @@ async def test_direct_openai_gpt5_uses_responses_api() -> None:
assert call_kwargs["max_output_tokens"] == 4096
assert "input" in call_kwargs
assert "messages" not in call_kwargs
assert call_kwargs["include"] == ["reasoning.encrypted_content"]
@pytest.mark.asyncio
@@ -710,6 +712,40 @@ async def test_direct_openai_reasoning_prefers_responses_api() -> None:
assert call_kwargs["include"] == ["reasoning.encrypted_content"]
@pytest.mark.asyncio
async def test_direct_openai_retries_without_unsupported_server_compaction() -> None:
mock_chat = AsyncMock(return_value=_fake_chat_response())
mock_responses = AsyncMock(side_effect=[
_FakeResponsesError(400, "Unknown parameter: context_management"),
_fake_responses_response("compaction fallback"),
])
spec = find_by_name("openai")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_class:
client_instance = mock_client_class.return_value
client_instance.chat.completions.create = mock_chat
client_instance.responses.create = mock_responses
provider = OpenAICompatProvider(
api_key="sk-test-key",
default_model="gpt-5.6",
spec=spec,
)
result = await provider.chat_with_context(
messages=[{"role": "user", "content": "hello"}],
model="gpt-5.6",
provider_context=ProviderCallContext(context_window_tokens=200_000),
)
assert result.content == "compaction fallback"
assert result.provider_state is not None
assert mock_responses.await_count == 2
assert "context_management" in mock_responses.call_args_list[0].kwargs
assert "context_management" not in mock_responses.call_args_list[1].kwargs
assert provider.supports_native_compaction("gpt-5.6") is False
mock_chat.assert_not_awaited()
@pytest.mark.asyncio
async def test_direct_openai_gpt4o_stays_on_chat_completions() -> None:
mock_chat = AsyncMock(return_value=_fake_chat_response())
+301 -5
View File
@@ -20,6 +20,7 @@ from nanobot.providers.openai_codex_provider import (
_request_codex,
_should_retry_status,
)
from nanobot.providers.openai_responses import build_responses_state
from nanobot.providers.registry import find_by_name
@@ -115,6 +116,48 @@ async def test_codex_request_non_200_populates_http_metadata(monkeypatch) -> Non
assert error.should_retry is True
@pytest.mark.asyncio
async def test_codex_request_marks_rejected_compaction_without_retaining_raw_body(
monkeypatch,
) -> None:
original_client = httpx.AsyncClient
secret = "PRIVATE PROMPT MUST NOT BE RETAINED"
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
400,
json={
"error": {
"message": f"Unknown input type compaction_trigger; {secret}",
},
},
request=request,
)
def fake_client(
*,
timeout: int,
verify: bool,
**_kwargs: object,
) -> httpx.AsyncClient:
return original_client(transport=httpx.MockTransport(handler), timeout=timeout)
monkeypatch.setattr("nanobot.providers.openai_codex_provider.httpx.AsyncClient", fake_client)
with pytest.raises(_CodexHTTPError) as caught:
await _request_codex(
"https://codex.example/responses",
{},
{"input": [{"type": "compaction_trigger"}]},
verify=True,
)
error = caught.value
assert error.compaction_unsupported is True
assert secret not in str(error)
assert not hasattr(error, "body")
@pytest.mark.asyncio
async def test_codex_request_honors_stream_idle_timeout_env(monkeypatch) -> None:
"""NANOBOT_STREAM_IDLE_TIMEOUT_S overrides the default Codex stream timeout."""
@@ -192,7 +235,7 @@ async def test_codex_prompt_cache_key_uses_stable_conversation_prefix(monkeypatc
):
_ = proxy, on_thinking_delta, on_tool_call_delta
bodies.append(body)
return "ok", [], "stop", {}, None
return provider_base.LLMResponse(content="ok")
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
@@ -232,7 +275,7 @@ async def test_codex_provider_applies_extra_body_from_config(monkeypatch) -> Non
async def fake_request(_url, _headers, body, **_kwargs):
bodies.append(body)
return "ok", [], "stop", {}, None
return provider_base.LLMResponse(content="ok")
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
config = Config.model_validate({
@@ -297,7 +340,7 @@ async def test_codex_provider_passes_proxy_to_oauth_and_response_request(monkeyp
):
_ = url, headers, body, verify, on_content_delta, on_thinking_delta, on_tool_call_delta
seen["request_proxy"] = proxy
return "ok", [], "stop", {}, None
return provider_base.LLMResponse(content="ok")
monkeypatch.setattr("nanobot.providers.openai_codex_provider.get_codex_token", fake_token)
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
@@ -384,7 +427,7 @@ async def test_codex_retry_uses_structured_timeout_metadata(monkeypatch) -> None
calls += 1
if calls == 1:
raise httpx.ReadTimeout("")
return "ok", [], "stop", {}, None
return provider_base.LLMResponse(content="ok")
async def fake_sleep(delay: float) -> None:
delays.append(delay)
@@ -533,6 +576,254 @@ def test_codex_reasoning_options_request_summary_without_forcing_effort() -> Non
assert _build_reasoning_options("none") == {"effort": "none"}
@pytest.mark.asyncio
async def test_codex_replayed_tool_turn_omits_server_item_ids(monkeypatch) -> None:
_mock_codex_token(monkeypatch)
provider = OpenAICodexProvider(default_model="openai-codex/gpt-5.6-sol")
state = build_responses_state(
provider=provider._responses_state_provider(),
model="gpt-5.6-sol",
input_items=[{
"id": "msg_user",
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Check the weather"}],
}],
output_items=[
{
"id": "rs_reasoning",
"type": "reasoning",
"encrypted_content": "opaque reasoning",
"summary": [],
},
{
"id": "fc_read",
"type": "function_call",
"call_id": "call_read",
"name": "read_file",
"arguments": '{"path":"weather/SKILL.md"}',
"status": "completed",
},
],
)
bodies: list[dict[str, Any]] = []
async def fake_request(
url,
headers,
body,
verify,
proxy=None,
on_content_delta=None,
on_thinking_delta=None,
on_tool_call_delta=None,
):
bodies.append(body)
return provider_base.LLMResponse(content="done")
monkeypatch.setattr(
"nanobot.providers.openai_codex_provider._request_codex",
fake_request,
)
response = await provider.chat(
[{"role": "user", "content": "Check the weather"}],
provider_context=provider_base.ProviderCallContext(
conversation_state=state.with_pending_messages([{
"role": "tool",
"tool_call_id": "call_read|fc_read",
"content": "weather skill contents",
}]),
),
)
assert response.content == "done"
assert len(bodies) == 1
input_items = bodies[0]["input"]
assert [item.get("type") for item in input_items] == [
"message",
"reasoning",
"function_call",
"function_call_output",
]
assert all("id" not in item for item in input_items)
assert input_items[1]["encrypted_content"] == "opaque reasoning"
assert input_items[2]["call_id"] == "call_read"
assert input_items[3]["call_id"] == "call_read"
@pytest.mark.asyncio
async def test_codex_compacts_state_at_ninety_percent_before_next_request(
monkeypatch,
) -> None:
_mock_codex_token(monkeypatch)
provider = OpenAICodexProvider(default_model="openai-codex/gpt-5.6-sol")
state_provider = provider._responses_state_provider()
state = build_responses_state(
provider=state_provider,
model="gpt-5.6-sol",
input_items=[{"type": "message", "role": "user", "content": "old question"}],
output_items=[
{"type": "reasoning", "encrypted_content": "old opaque reasoning"},
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "old answer"}],
},
],
usage={
"prompt_tokens": 90,
"completion_tokens": 5,
"total_tokens": 95,
},
)
bodies: list[dict[str, Any]] = []
async def fake_request(
url,
headers,
body,
verify,
proxy=None,
on_content_delta=None,
on_thinking_delta=None,
on_tool_call_delta=None,
):
_ = (
url,
headers,
verify,
proxy,
on_content_delta,
on_thinking_delta,
on_tool_call_delta,
)
bodies.append(body)
if body["input"][-1].get("type") == "compaction_trigger":
compact_item = {
"type": "compaction",
"encrypted_content": "compacted opaque state",
}
return provider_base.LLMResponse(
content=None,
provider_state=build_responses_state(
provider=state_provider,
model="gpt-5.6-sol",
input_items=body["input"],
output_items=[compact_item],
usage={
"prompt_tokens": 95,
"completion_tokens": 2,
"total_tokens": 97,
},
),
)
return provider_base.LLMResponse(content="done")
monkeypatch.setattr(
"nanobot.providers.openai_codex_provider._request_codex",
fake_request,
)
response = await provider.chat_with_retry(
[
{"role": "system", "content": "system"},
{"role": "user", "content": "new question"},
],
max_tokens=5,
provider_context=provider_base.ProviderCallContext(
conversation_state=state.with_pending_messages([
{"role": "user", "content": "new question"},
]),
context_window_tokens=100,
),
)
assert response.content == "done"
assert len(bodies) == 2
assert bodies[0]["input"][-1] == {"type": "compaction_trigger"}
assert bodies[1]["input"][-1] == {
"type": "compaction",
"encrypted_content": "compacted opaque state",
}
assert not any(
item.get("type") == "reasoning"
for item in bodies[1]["input"]
)
assert any(
item.get("role") == "user"
and "new question" in str(item.get("content"))
for item in bodies[1]["input"]
)
@pytest.mark.asyncio
async def test_codex_disables_unsupported_native_compaction_and_continues(
monkeypatch,
) -> None:
_mock_codex_token(monkeypatch)
provider = OpenAICodexProvider(default_model="openai-codex/gpt-5.6-sol")
state_provider = provider._responses_state_provider()
state = build_responses_state(
provider=state_provider,
model="gpt-5.6-sol",
input_items=[{"type": "message", "role": "user", "content": "old"}],
output_items=[{"type": "reasoning", "encrypted_content": "opaque"}],
usage={"prompt_tokens": 90, "completion_tokens": 5, "total_tokens": 95},
)
bodies: list[dict[str, Any]] = []
async def fake_request(
url,
headers,
body,
verify,
proxy=None,
on_content_delta=None,
on_thinking_delta=None,
on_tool_call_delta=None,
):
_ = (
url,
headers,
verify,
proxy,
on_content_delta,
on_thinking_delta,
on_tool_call_delta,
)
bodies.append(body)
if body["input"][-1].get("type") == "compaction_trigger":
raise _CodexHTTPError(
"HTTP 400: Codex API request failed",
status_code=400,
compaction_unsupported=True,
)
return provider_base.LLMResponse(content="done")
monkeypatch.setattr(
"nanobot.providers.openai_codex_provider._request_codex",
fake_request,
)
response = await provider.chat(
[{"role": "user", "content": "new"}],
max_tokens=5,
provider_context=provider_base.ProviderCallContext(
conversation_state=state.with_pending_messages([
{"role": "user", "content": "new"},
]),
context_window_tokens=100,
),
)
assert response.content == "done"
assert len(bodies) == 2
assert bodies[0]["input"][-1] == {"type": "compaction_trigger"}
assert bodies[1]["input"][-1] != {"type": "compaction_trigger"}
assert provider.supports_native_compaction() is False
@pytest.mark.asyncio
async def test_codex_stream_surfaces_reasoning_summary(monkeypatch) -> None:
def fake_token(**_kwargs):
@@ -559,7 +850,12 @@ async def test_codex_stream_surfaces_reasoning_summary(monkeypatch) -> None:
await on_content_delta("answer")
if on_thinking_delta:
await on_thinking_delta("summary")
return "answer", [], "stop", {"prompt_tokens": 10, "completion_tokens": 5}, "summary"
return provider_base.LLMResponse(
content="answer",
finish_reason="stop",
usage={"prompt_tokens": 10, "completion_tokens": 5},
reasoning_content="summary",
)
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
+706 -5
View File
@@ -1,9 +1,11 @@
"""Tests for the shared openai_responses converters and parsers."""
import json
from io import StringIO
from unittest.mock import MagicMock, patch
import pytest
from loguru import logger
from nanobot.providers.openai_responses.converters import (
convert_messages,
@@ -12,12 +14,22 @@ from nanobot.providers.openai_responses.converters import (
split_tool_call_id,
)
from nanobot.providers.openai_responses.parsing import (
ResponsesStreamCapture,
consume_sdk_stream,
consume_sse,
consume_sse_with_reasoning,
is_replayable_finish_reason,
map_finish_reason,
parse_response_output,
)
from nanobot.providers.openai_responses.state import (
build_responses_state,
is_compaction_compatibility_error,
prepare_responses_input,
resolve_compact_threshold,
responses_state_context_tokens,
responses_state_items,
)
# ======================================================================
# converters - split_tool_call_id
@@ -398,6 +410,17 @@ class TestMapFinishReason:
def test_unknown_defaults_to_stop(self):
assert map_finish_reason("some_new_status") == "stop"
@pytest.mark.parametrize("finish_reason", ["stop", "tool_calls", "function_call"])
def test_replayable_finish_reasons(self, finish_reason):
assert is_replayable_finish_reason(finish_reason) is True
@pytest.mark.parametrize(
"finish_reason",
["length", "refusal", "content_filter", "error"],
)
def test_non_replayable_finish_reasons(self, finish_reason):
assert is_replayable_finish_reason(finish_reason) is False
# ======================================================================
# parsing - parse_response_output
@@ -418,6 +441,29 @@ class TestParseResponseOutput:
assert result.usage == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
assert result.tool_calls == []
def test_refusal_response_surfaces_text_without_advancing_state(self):
refusal = "I cant help with that request."
resp = {
"output": [{
"type": "message",
"role": "assistant",
"content": [{"type": "refusal", "refusal": refusal}],
}],
"status": "completed",
"usage": {},
}
result = parse_response_output(
resp,
state_provider="openai:test",
state_model="gpt-5.6",
state_input_items=[{"role": "user", "content": "request"}],
)
assert result.content == refusal
assert result.finish_reason == "refusal"
assert result.provider_state is None
def test_tool_call_response(self):
resp = {
"output": [{
@@ -429,12 +475,18 @@ class TestParseResponseOutput:
"status": "completed",
"usage": {},
}
result = parse_response_output(resp)
result = parse_response_output(
resp,
state_provider="openai:test",
state_model="gpt-5.6",
state_input_items=[{"role": "user", "content": "weather?"}],
)
assert result.content is None
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "get_weather"
assert result.tool_calls[0].arguments == {"city": "SF"}
assert result.tool_calls[0].id == "call_1|fc_1"
assert result.provider_state is not None
def test_malformed_tool_arguments_logged(self):
"""Malformed JSON arguments should log a warning and remain non-object."""
@@ -493,10 +545,39 @@ class TestParseResponseOutput:
assert result.content is None
assert result.tool_calls == []
def test_incomplete_status(self):
resp = {"output": [], "status": "incomplete", "usage": {}}
result = parse_response_output(resp)
assert result.finish_reason == "length"
@pytest.mark.parametrize(
("reason", "expected_finish_reason"),
[
("max_output_tokens", "length"),
("content_filter", "content_filter"),
],
)
def test_incomplete_status(self, reason, expected_finish_reason):
resp = {
"output": [],
"status": "incomplete",
"incomplete_details": {"reason": reason},
"usage": {},
}
result = parse_response_output(
resp,
state_provider="openai:test",
state_model="gpt-5.6",
state_input_items=[{"role": "user", "content": "prompt"}],
)
assert result.finish_reason == expected_finish_reason
assert result.provider_state is None
def test_unknown_status_does_not_advance_provider_state(self):
result = parse_response_output(
{"output": [], "status": "future_terminal_status", "usage": {}},
state_provider="openai:test",
state_model="gpt-5.6",
state_input_items=[{"role": "user", "content": "prompt"}],
)
assert result.finish_reason == "stop"
assert result.provider_state is None
def test_sdk_model_object(self):
"""parse_response_output should handle SDK objects with model_dump()."""
@@ -523,6 +604,194 @@ class TestParseResponseOutput:
assert result.usage["completion_tokens"] == 50
assert result.usage["total_tokens"] == 150
def test_preserves_every_output_item_as_opaque_state(self):
input_items = [{"role": "user", "content": "inspect the repo"}]
output = [
{
"id": "rs_1",
"type": "reasoning",
"encrypted_content": "opaque-secret",
"summary": [],
},
{
"id": "future_1",
"type": "future_item_type",
"provider_field": {"nested": True},
},
{
"id": "msg_1",
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "done"}],
},
]
result = parse_response_output(
{"output": output, "status": "completed", "usage": {}},
state_provider="openai:test",
state_model="gpt-5.6",
state_input_items=input_items,
)
assert result.provider_state is not None
assert responses_state_items(result.provider_state) == [*input_items, *output]
class TestResponsesConversationState:
def test_server_compaction_prunes_superseded_prefix(self):
state = build_responses_state(
provider="openai:test",
model="gpt-5.6",
input_items=[
{"type": "message", "role": "user", "content": "old"},
{"type": "reasoning", "encrypted_content": "old-reasoning"},
],
output_items=[
{"type": "compaction", "encrypted_content": "compact"},
{"type": "message", "role": "assistant", "content": "new"},
],
usage={
"prompt_tokens": 90,
"completion_tokens": 10,
"total_tokens": 100,
},
)
assert responses_state_items(state) == [
{"type": "compaction", "encrypted_content": "compact"},
{"type": "message", "role": "assistant", "content": "new"},
]
assert responses_state_context_tokens(state) == 100
def test_existing_compaction_keeps_canonical_retained_prefix(self):
canonical_input = [
{"type": "message", "role": "user", "content": "retained"},
{"type": "compaction", "encrypted_content": "compact"},
]
output = [{"type": "message", "role": "assistant", "content": "new"}]
state = build_responses_state(
provider="openai:test",
model="gpt-5.6",
input_items=canonical_input,
output_items=output,
)
assert responses_state_items(state) == [*canonical_input, *output]
@pytest.mark.parametrize(
("context_window", "max_output", "expected"),
[
(200_000, 20_000, 180_000),
(100_000, 30_000, 70_000),
(0, 4_096, None),
],
)
def test_compact_threshold_reserves_codex_style_headroom(
self,
context_window,
max_output,
expected,
):
assert resolve_compact_threshold(context_window, max_output) == expected
def test_compaction_compatibility_recognizes_old_sdk_signature_error(self):
error = TypeError("create() got an unexpected keyword argument 'context_management'")
assert is_compaction_compatibility_error(error) is True
assert is_compaction_compatibility_error(TypeError("unrelated argument")) is False
def test_state_observability_logs_counts_without_opaque_content(self):
secret = "opaque-secret-that-must-not-be-logged"
state = build_responses_state(
provider=f"openai:https://example.test/?key={secret}",
model=f"secret-model-{secret}",
input_items=[{"role": "user", "content": secret}],
output_items=[{"type": "reasoning", "encrypted_content": secret}],
).with_pending_messages([{"role": "user", "content": secret}])
sink = StringIO()
sink_id = logger.add(sink, level="DEBUG", format="{message}")
try:
prepare_responses_input(
[{"role": "user", "content": secret}],
state=state,
provider=state.provider,
model=state.model,
)
build_responses_state(
provider=state.provider,
model=state.model,
input_items=[
{"role": "user", "content": secret},
{"type": "reasoning", "encrypted_content": secret},
],
output_items=[
{"type": "compaction", "encrypted_content": secret},
],
)
finally:
logger.remove(sink_id)
log_text = sink.getvalue()
assert "prior_items=2" in log_text
assert "pending_messages=1" in log_text
assert "dropped_items=2" in log_text
assert secret not in log_text
def test_replays_exact_items_then_only_pending_and_new_messages(self):
prior_items = [
{"role": "user", "content": "first"},
{
"type": "reasoning",
"id": "rs_1",
"encrypted_content": "opaque-secret",
},
{
"type": "function_call",
"id": "fc_1",
"call_id": "call_1",
"name": "read_file",
"arguments": '{"path":"a.py"}',
},
]
state = build_responses_state(
provider="openai:test",
model="gpt-5.6",
input_items=prior_items[:1],
output_items=prior_items[1:],
).with_pending_messages([
{
"role": "tool",
"tool_call_id": "call_1|fc_1",
"content": "file contents",
},
{"role": "user", "content": "continue"},
])
instructions, items, replayed = prepare_responses_input(
[
{"role": "system", "content": "current instructions"},
{"role": "user", "content": "a lossy public transcript"},
],
state=state,
provider="openai:test",
model="gpt-5.6",
)
assert instructions == "current instructions"
assert replayed is True
assert items[:3] == prior_items
assert items[3] == {
"type": "function_call_output",
"call_id": "call_1",
"output": "file contents",
}
assert items[4] == {
"role": "user",
"content": [{"type": "input_text", "text": "continue"}],
}
assert "lossy public transcript" not in str(items)
# ======================================================================
# parsing - consume_sse
@@ -553,6 +822,122 @@ class TestConsumeSse:
assert tool_calls == []
assert finish_reason == "stop"
@pytest.mark.asyncio
async def test_refusal_events_reconcile_parts_and_terminal_output(self):
refusal = "First and second sentence. Done-only. Terminal suffix."
terminal_response = {
"status": "completed",
"output": [{
"type": "message",
"id": "msg_2",
"role": "assistant",
"content": [{"type": "refusal", "refusal": refusal}],
}],
}
response = _SseResponse([
{
"type": "response.refusal.delta",
"item_id": "msg_1",
"content_index": 0,
"delta": "First",
},
{
"type": "response.refusal.delta",
"item_id": "msg_1",
"content_index": 1,
"delta": " and second",
},
{
"type": "response.refusal.done",
"item_id": "msg_1",
"content_index": 0,
"refusal": "First",
},
{
"type": "response.refusal.done",
"item_id": "msg_1",
"content_index": 1,
"refusal": " and second sentence.",
},
{
"type": "response.refusal.done",
"item_id": "msg_2",
"content_index": 0,
"refusal": " Done-only.",
},
{
"type": "response.refusal.delta",
"item_id": "msg_2",
"content_index": 1,
"delta": " Terminal",
},
{"type": "response.completed", "response": terminal_response},
])
capture = ResponsesStreamCapture()
deltas: list[str] = []
async def on_content(delta: str) -> None:
deltas.append(delta)
content, _, finish_reason, _, _ = await consume_sse_with_reasoning(
response,
on_content_delta=on_content,
capture=capture,
)
assert content == refusal
assert deltas == [
"First",
" and second",
" sentence.",
" Done-only.",
" Terminal",
" suffix.",
]
assert finish_reason == "refusal"
assert capture.completed is True
assert is_replayable_finish_reason(finish_reason) is False
@pytest.mark.asyncio
@pytest.mark.parametrize("source", ["events", "terminal"])
async def test_refusal_without_deltas_has_non_replayable_finish(self, source: str):
refusal = "I cant help with that request."
terminal_response = {
"status": "completed",
"output": [{
"type": "message",
"id": "msg_1",
"role": "assistant",
"content": [{"type": "refusal", "refusal": refusal}],
}],
}
events = (
[
{"type": "response.refusal.done", "refusal": refusal},
{"type": "response.completed", "response": {"status": "completed"}},
]
if source == "events"
else [{"type": "response.completed", "response": terminal_response}]
)
response = _SseResponse(events)
capture = ResponsesStreamCapture()
deltas: list[str] = []
async def on_content(delta: str) -> None:
deltas.append(delta)
content, _, finish_reason, _, _ = await consume_sse_with_reasoning(
response,
on_content_delta=on_content,
capture=capture,
)
assert content == refusal
assert deltas == [refusal]
assert finish_reason == "refusal"
assert capture.completed is True
assert is_replayable_finish_reason(finish_reason) is False
@pytest.mark.asyncio
async def test_reasoning_summary_delta_extracted(self):
response = _SseResponse([
@@ -599,6 +984,139 @@ class TestConsumeSse:
assert reasoning == "cached summary"
@pytest.mark.asyncio
async def test_capture_commits_exact_items_only_after_completed_event(self):
output = [
{
"type": "reasoning",
"id": "rs_1",
"encrypted_content": "opaque-secret",
},
{"type": "future_item_type", "id": "future_1", "value": 7},
]
capture = ResponsesStreamCapture()
response = _SseResponse([
{
"type": "response.output_item.done",
"output_index": 0,
"item": output[0],
},
{
"type": "response.output_item.done",
"output_index": 1,
"item": output[1],
},
{
"type": "response.completed",
"response": {"status": "completed", "output": output},
},
])
await consume_sse_with_reasoning(response, capture=capture)
assert capture.completed is True
assert capture.output_items == output
@pytest.mark.asyncio
async def test_capture_keeps_done_items_when_completed_output_is_empty(self):
output = [
{
"type": "reasoning",
"id": "rs_1",
"encrypted_content": "opaque-secret",
"summary": [],
},
{
"type": "function_call",
"id": "fc_1",
"call_id": "call_1",
"name": "read_file",
"arguments": '{"path":"weather/SKILL.md"}',
},
]
capture = ResponsesStreamCapture()
response = _SseResponse([
{
"type": "response.output_item.done",
"output_index": index,
"item": item,
}
for index, item in enumerate(output)
] + [{
"type": "response.completed",
"response": {"status": "completed", "output": []},
}])
await consume_sse_with_reasoning(response, capture=capture)
assert capture.completed is True
assert capture.output_items == output
@pytest.mark.asyncio
@pytest.mark.parametrize(
("reason", "expected_finish_reason"),
[
("max_output_tokens", "length"),
("content_filter", "content_filter"),
],
)
async def test_incomplete_event_commits_capture_usage(
self,
reason,
expected_finish_reason,
):
output = [
{
"type": "message",
"id": "msg_1",
"status": "incomplete",
"content": [{"type": "output_text", "text": "partial"}],
},
]
terminal_response = {
"id": "resp_1",
"status": "incomplete",
"incomplete_details": {"reason": reason},
"output": output,
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
}
capture = ResponsesStreamCapture()
response = _SseResponse([
{"type": "response.output_text.delta", "delta": "partial"},
{"type": "response.incomplete", "response": terminal_response},
])
content, _, finish_reason, usage, _ = await consume_sse_with_reasoning(
response,
capture=capture,
)
assert content == "partial"
assert finish_reason == expected_finish_reason
assert usage == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
assert capture.completed is True
assert capture.response == terminal_response
assert capture.output_items == output
@pytest.mark.asyncio
async def test_capture_does_not_commit_interrupted_stream(self):
capture = ResponsesStreamCapture()
response = _SseResponse([
{
"type": "response.output_item.done",
"output_index": 0,
"item": {
"type": "reasoning",
"id": "rs_1",
"encrypted_content": "opaque-secret",
},
},
])
await consume_sse_with_reasoning(response, capture=capture)
assert capture.completed is False
@pytest.mark.asyncio
async def test_reasoning_summary_from_done_item(self):
response = _SseResponse([
@@ -755,6 +1273,131 @@ class TestConsumeSdkStream:
assert tool_calls == []
assert finish_reason == "stop"
@pytest.mark.asyncio
async def test_refusal_events_reconcile_parts_and_terminal_output(self):
refusal = "First and second sentence. Done-only. Terminal suffix."
terminal_response = {
"status": "completed",
"output": [{
"type": "message",
"id": "msg_2",
"role": "assistant",
"content": [{"type": "refusal", "refusal": refusal}],
}],
}
resp_obj = MagicMock(status="completed", usage=None, output=[])
resp_obj.model_dump.return_value = terminal_response
events = [
MagicMock(
type="response.refusal.delta",
item_id="msg_1",
content_index=0,
delta="First",
),
MagicMock(
type="response.refusal.delta",
item_id="msg_1",
content_index=1,
delta=" and second",
),
MagicMock(
type="response.refusal.done",
item_id="msg_1",
content_index=0,
refusal="First",
),
MagicMock(
type="response.refusal.done",
item_id="msg_1",
content_index=1,
refusal=" and second sentence.",
),
MagicMock(
type="response.refusal.done",
item_id="msg_2",
content_index=0,
refusal=" Done-only.",
),
MagicMock(
type="response.refusal.delta",
item_id="msg_2",
content_index=1,
delta=" Terminal",
),
MagicMock(type="response.completed", response=resp_obj),
]
capture = ResponsesStreamCapture()
deltas: list[str] = []
async def on_content(delta: str) -> None:
deltas.append(delta)
async def stream():
for event in events:
yield event
content, _, finish_reason, _, _ = await consume_sdk_stream(
stream(),
on_content_delta=on_content,
capture=capture,
)
assert content == refusal
assert deltas == [
"First",
" and second",
" sentence.",
" Done-only.",
" Terminal",
" suffix.",
]
assert finish_reason == "refusal"
assert capture.completed is True
assert is_replayable_finish_reason(finish_reason) is False
@pytest.mark.asyncio
@pytest.mark.parametrize("source", ["events", "terminal"])
async def test_refusal_without_deltas_has_non_replayable_finish(self, source: str):
refusal = "I cant help with that request."
terminal_response = {
"status": "completed",
"output": [{
"type": "message",
"id": "msg_1",
"role": "assistant",
"content": [{"type": "refusal", "refusal": refusal}],
}],
}
resp_obj = MagicMock(status="completed", usage=None, output=[])
resp_obj.model_dump.return_value = terminal_response
capture = ResponsesStreamCapture()
deltas: list[str] = []
async def on_content(delta: str) -> None:
deltas.append(delta)
async def stream():
if source == "events":
yield MagicMock(type="response.refusal.done", refusal=refusal)
yield MagicMock(
type="response.completed",
response={"status": "completed"},
)
else:
yield MagicMock(type="response.completed", response=resp_obj)
content, _, finish_reason, _, _ = await consume_sdk_stream(
stream(),
on_content_delta=on_content,
capture=capture,
)
assert content == refusal
assert deltas == [refusal]
assert finish_reason == "refusal"
assert capture.completed is True
assert is_replayable_finish_reason(finish_reason) is False
@pytest.mark.asyncio
async def test_on_content_delta_called(self):
ev1 = MagicMock(type="response.output_text.delta", delta="hi")
@@ -919,6 +1562,64 @@ class TestConsumeSdkStream:
_, _, _, usage, _ = await consume_sdk_stream(stream())
assert usage == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
@pytest.mark.asyncio
@pytest.mark.parametrize(
("reason", "expected_finish_reason"),
[
("max_output_tokens", "length"),
("content_filter", "content_filter"),
],
)
async def test_incomplete_event_commits_capture_usage(
self,
reason,
expected_finish_reason,
):
output = [
{
"type": "message",
"id": "msg_1",
"status": "incomplete",
"content": [{"type": "output_text", "text": "partial"}],
},
]
usage_obj = MagicMock(input_tokens=10, output_tokens=5, total_tokens=15)
output_item = MagicMock(type="message")
terminal_response = {
"id": "resp_1",
"status": "incomplete",
"incomplete_details": {"reason": reason},
"output": output,
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
}
resp_obj = MagicMock(
status="incomplete",
usage=usage_obj,
output=[output_item],
)
resp_obj.model_dump.return_value = terminal_response
events = [
MagicMock(type="response.output_text.delta", delta="partial"),
MagicMock(type="response.incomplete", response=resp_obj),
]
capture = ResponsesStreamCapture()
async def stream():
for event in events:
yield event
content, _, finish_reason, usage, _ = await consume_sdk_stream(
stream(),
capture=capture,
)
assert content == "partial"
assert finish_reason == expected_finish_reason
assert usage == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
assert capture.completed is True
assert capture.response == terminal_response
assert capture.output_items == output
@pytest.mark.asyncio
async def test_reasoning_extracted(self):
summary_item = MagicMock(type="summary_text", text="thinking...")
+81 -1
View File
@@ -3,7 +3,14 @@ import copy
import pytest
from nanobot.providers.base import RETRY_AFTER_BUFFER, GenerationSettings, LLMProvider, LLMResponse
from nanobot.providers.base import (
RETRY_AFTER_BUFFER,
GenerationSettings,
LLMProvider,
LLMResponse,
ProviderCallContext,
ProviderConversationState,
)
class ScriptedProvider(LLMProvider):
@@ -330,6 +337,79 @@ async def test_successful_image_retry_mutates_original_messages_in_place() -> No
assert any("not delivered" in (block.get("text") or "").lower() for block in content)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("messages", "payload", "pending_messages"),
[
(_IMAGE_MSG, {}, _IMAGE_MSG),
(
[{"role": "user", "content": "continue"}],
{
"items": [
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_image",
"image_url": "data:image/png;base64,abc",
}
],
}
]
},
[],
),
],
ids=["pending-image", "opaque-payload-image"],
)
async def test_image_retry_discards_provider_state_with_images(
messages,
payload,
pending_messages,
) -> None:
class ContextScriptedProvider(ScriptedProvider):
def __init__(self, responses):
super().__init__(responses)
self.contexts: list[ProviderCallContext] = []
async def chat_with_context(
self,
*,
provider_context: ProviderCallContext,
**kwargs,
) -> LLMResponse:
self.contexts.append(provider_context)
return await self.chat(**kwargs)
provider = ContextScriptedProvider([
LLMResponse(content="model does not support images", finish_reason="error"),
LLMResponse(content="ok, no image"),
])
messages = copy.deepcopy(messages)
state = ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="gpt-5.6",
version=1,
payload=copy.deepcopy(payload),
pending_messages=copy.deepcopy(pending_messages),
)
response = await provider.chat_with_retry(
messages=messages,
provider_context=ProviderCallContext(conversation_state=state),
)
assert response.content == "ok, no image"
retry_context = provider.contexts[-1]
assert isinstance(retry_context, ProviderCallContext)
assert retry_context.conversation_state is None
public_content = messages[0]["content"]
if isinstance(public_content, list):
assert all(block.get("type") != "image_url" for block in public_content)
@pytest.mark.asyncio
async def test_non_transient_error_without_images_no_retry() -> None:
"""Non-transient errors without image content are returned immediately."""
@@ -4,6 +4,7 @@ import time
import pytest
from nanobot.providers.base import ProviderCallContext
from nanobot.providers.openai_compat_provider import (
_RESPONSES_FAILURE_THRESHOLD,
_RESPONSES_PROBE_INTERVAL_S,
@@ -28,6 +29,26 @@ def test_responses_api_available_by_default(provider):
assert provider._should_use_responses_api("gpt-5", None) is True
def test_direct_openai_enables_server_compaction(provider):
provider._extra_body = {}
body = provider._build_responses_body(
messages=[{"role": "user", "content": "hello"}],
tools=None,
model="gpt-5.6",
max_tokens=30_000,
temperature=0.1,
reasoning_effort="high",
tool_choice=None,
provider_context=ProviderCallContext(context_window_tokens=100_000),
)
assert body["context_management"] == [{
"type": "compaction",
"compact_threshold": 70_000,
}]
def test_api_type_chat_completions_disables_responses(provider):
provider._api_type = "chat_completions"
assert provider._should_use_responses_api("gpt-5", None) is False
+21
View File
@@ -8,6 +8,7 @@ import pytest
import nanobot.webui.session_list_index as session_list_index
from nanobot.cron.session_turns import CRON_HISTORY_META
from nanobot.providers.base import ProviderConversationState
from nanobot.session.automation_turns import AUTOMATION_HISTORY_META
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
from nanobot.session.manager import SessionManager
@@ -85,6 +86,26 @@ def test_webui_session_list_rescans_only_changed_file(tmp_path: Path, monkeypatc
assert {row["preview"] for row in rows} == {"first", "second after"}
def test_webui_session_list_skips_provider_state_before_preview_budget(
tmp_path: Path,
monkeypatch,
) -> None:
monkeypatch.setattr(session_list_index, "_SESSION_LIST_PREVIEW_MAX_CHARS", 100)
manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:private-state")
session.provider_state = ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="test-model",
version=1,
payload={"items": [{"encrypted_content": "x" * 200}]},
)
session.add_message("user", "visible preview")
manager.save(session)
assert list_webui_sessions(manager)[0]["preview"] == "visible preview"
def test_webui_session_list_drops_deleted_index_rows(tmp_path: Path) -> None:
manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:deleted")