Compare commits

..
Author SHA1 Message Date
Xubin Ren 3d14bcaf72 refactor(webui): reuse sidebar selection highlight 2026-07-31 00:59:35 +08:00
Xubin Ren 47d83af0b6 feat(webui): add persistent Quick Chat 2026-07-31 00:53:10 +08:00
chengyongruandGitHub 6a1a45d07a feat: preserve Responses reasoning state and compact context (#5172) 2026-07-30 22:39:43 +08:00
Solaris-starandXubin Ren 511c764f45 fix(agent): route finish_reason='length' with blank content to length recovery
When an LLM response arrives with finish_reason='length' and has_tool_calls
but blank text content (e.g. the model spent its whole output budget on a
tool call whose closing tag was truncated), the runner dropped the tool
calls and then misrouted the blank response into the empty-response retry
branch. Retrying the same prompt cannot recover from output-budget
exhaustion, so every retry hit the same length ceiling and the turn ended
in the generic apology.

The length-recovery branch was gated on 'finish_reason == length and not
is_blank_text(clean)', so a blank-but-truncated turn could never reach it.

- The empty-response retry branch now excludes finish_reason == 'length'
  (in addition to 'error').
- The length-recovery branch no longer requires non-blank content, so a
  blank-but-truncated turn enters recovery and appends
  build_length_recovery_message (which handles a blank tail safely).

Adds a regression test asserting the length-recovery path is taken; it
fails on the unfixed code and passes with the fix.

Fixes #5133
2026-07-30 19:55:19 +08:00
Xubin Ren 0eac82984c test(mcp): stabilize idle reconnect timing 2026-07-30 19:44:09 +08:00
yu-xin-candXubin Ren 5e67fbf93e fix(exec): bound buffered session output 2026-07-30 19:44:09 +08:00
yu-xin-candXubin Ren 9ec4420104 fix(agent): release idle session locks 2026-07-30 19:17:37 +08:00
KDBandXubin Ren 52680dbe19 fix(pairing): keep approvals across transient store read failures
_load() treated any OSError like corruption and returned an empty store. When pairing.json was transiently unreadable, an unapproved DM could deny the sender, generate a pairing code from the empty view, and overwrite the store without its approved senders.

Keep the existing JSONDecodeError reset behavior, but propagate OSError so mutations cannot persist unreadable state. Read-only checks fail closed without writing; mutating /pairing subcommands report temporary unavailability; and the DM pairing path skips one reply instead of crashing the handler.

This mirrors the refuse-to-overwrite strategy used by the cron and trigger stores.
2026-07-30 19:02:37 +08:00
KDBandXubin Ren e633f867e8 fix(session): tolerate invalid idle-compaction timestamps 2026-07-30 18:52:16 +08:00
KDBandXubin Ren 07c2677eed fix(webui): drop malformed token-usage day keys
normalize_token_usage_state only length-checked persisted day keys, so a
hand-edited or foreign 10-char key (e.g. "not-a-dat3" or "2026-13-01") in
token-usage.json survived reads and atomic rewrites. token_usage_payload
then parsed every day key with an unguarded datetime.fromisoformat, so one
such key failed every /api/settings and /api/settings/usage request until
the file was repaired by hand.

Validate day keys in normalize_token_usage_state, the shared boundary that
every read, record, and rewrite already funnels through. Malformed keys are
dropped like other malformed rows and scrubbed from the file on the next
write; valid state is unchanged.
2026-07-30 18:41:44 +08:00
92361cbeac fix(gitstore): return real git object ids instead of hex-of-hex
`porcelain.commit()` and `repo.refs[...]` hand back object ids as a
40-character hex string that is already encoded to bytes. Calling `.hex()`
on that encodes the ASCII a second time, so every id GitStore produced or
displayed was double-encoded:

    auto_commit()          -> '62623234'
    git log --abbrev=8     -> 'bb244606'

The module is self-consistently wrong, so `/dream-log` and `/dream-restore`
work as long as the id came from nanobot itself. What does not work is
crossing the boundary: ids in logs and commit output match nothing in
`git log`, and an id copied from `git log` cannot be resolved:

    _resolve_sha(own id)      -> b'bb244606d780...'
    _resolve_sha(real git id) -> None

Use `.decode()` at the four sites that consume dulwich object ids. Nothing
persists an id — callers either display it or resolve it live — so there is
no stored state in the old format.

Adds two regression tests: the id returned by `auto_commit` must equal
`git log --abbrev=8`, and a real git id must resolve through `_resolve_sha`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-30 18:25:56 +08:00
chengyongruandchengyongru bb2f6cf324 fix(webui): preserve automation source on streamed replies 2026-07-30 17:57:31 +08:00
87 changed files with 6202 additions and 496 deletions
+14
View File
@@ -348,6 +348,20 @@ Valid `apiType` values are exactly `auto`, `chat_completions`, and `responses`.
</details>
<a id="responses-state-and-compaction"></a>
### Responses conversation state and compaction
Providers that use the Responses API can keep reasoning context across a
conversation, which helps with multi-step tasks. Supported providers can also
compact long conversations automatically.
nanobot preserves Responses conversation state automatically for OpenAI
Responses, OpenAI Codex, Azure OpenAI, and compatible GitHub Copilot models.
Native compaction is also automatic when the provider supports it. The
threshold is derived from the active model's context window and reserved output
headroom; no provider configuration is required.
<details>
<summary><b>Azure OpenAI</b></summary>
+2 -2
View File
@@ -229,7 +229,7 @@ Arbitrary custom provider names are OpenAI-compatible only; they do not use the
}
```
`providers.openai.apiType` may be set when you need to force a specific OpenAI API surface. Other providers reject `apiType`; leave it unset outside `providers.openai`. Replace the model with a model ID available to your OpenAI account.
`providers.openai.apiType` may be set when you need to force a specific OpenAI API surface. Other providers reject `apiType`; leave it unset outside `providers.openai`. Replace the model with a model ID available to your OpenAI account. Direct OpenAI Responses, OpenAI Codex, Azure OpenAI Responses, and eligible GitHub Copilot models share [opaque Responses state retention](./configuration.md#responses-state-and-compaction); native compaction is enabled only where the backend supports it.
### Custom OpenAI-Compatible Endpoint
@@ -458,7 +458,7 @@ For GitHub Copilot:
nanobot provider login github-copilot --set-main
```
Each command authenticates the selected provider and makes its current default model active. OAuth providers are not valid automatic fallbacks. See [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems) for proxy, headless-login, model-name, and config-key errors.
Each command authenticates the selected provider and makes its current default model active. OpenAI Codex and eligible GitHub Copilot models participate in [Responses state retention](./configuration.md#responses-state-and-compaction), while native compaction remains provider-capability-specific. OAuth providers are not valid automatic fallbacks. See [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems) for proxy, headless-login, model-name, and config-key errors.
## Provider Resolution
+13 -3
View File
@@ -31,9 +31,19 @@ class AutoCompact:
now: datetime | None = None) -> bool:
if self._ttl <= 0 or not ts:
return False
if isinstance(ts, str):
ts = datetime.fromisoformat(ts)
return ((now or datetime.now()) - ts).total_seconds() >= self._ttl * 60
try:
if isinstance(ts, str):
ts = datetime.fromisoformat(ts)
current = now or datetime.now()
if getattr(ts, "tzinfo", None) is not None or current.tzinfo is not None:
idle_seconds = current.timestamp() - ts.timestamp()
else:
idle_seconds = (current - ts).total_seconds()
except (OSError, OverflowError, TypeError, ValueError):
# list_sessions() forwards raw persisted metadata; an unusable value
# must not escape the idle scan and stop the agent loop.
return False
return idle_seconds >= self._ttl * 60
def _has_compactable_idle_tail(self, key: str) -> bool:
session = self.sessions.get_or_create(key)
+32 -9
View File
@@ -225,9 +225,6 @@ class ContextBuilder:
if current_role == "user"
else []
)
user_content = self.build_user_content(current_message, image_paths=media)
blocks = list(runtime_context_blocks or ()) if current_role == "user" else []
merged, runtime_context_meta = append_runtime_context(user_content, blocks)
messages: list[dict[str, Any]] = [
{
"role": "system",
@@ -243,21 +240,47 @@ class ContextBuilder:
},
*history,
]
current = self.build_current_message(
current_message,
media=media,
current_role=current_role,
runtime_context_blocks=runtime_context_blocks,
)
if messages[-1].get("role") == current_role:
last = dict(messages[-1])
last["content"] = self._merge_message_content(last.get("content"), merged)
if current_role == "user" and runtime_context_meta is not None:
last["content"] = self._merge_message_content(
last.get("content"),
current.get("content"),
)
current_meta = current.get("_meta")
if current_role == "user" and isinstance(current_meta, dict):
internal_meta = dict(last.get("_meta") or {})
internal_meta[RUNTIME_CONTEXT_MESSAGE_META] = runtime_context_meta
internal_meta.update(cast(dict[str, Any], current_meta))
last["_meta"] = internal_meta
messages[-1] = last
return messages
current: dict[str, Any] = {"role": current_role, "content": merged}
if current_role == "user" and runtime_context_meta is not None:
current["_meta"] = {RUNTIME_CONTEXT_MESSAGE_META: runtime_context_meta}
messages.append(current)
return messages
def build_current_message(
self,
current_message: str,
*,
media: list[str] | None = None,
current_role: str = "user",
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None,
) -> dict[str, Any]:
"""Build only the fresh turn message without merging it into history."""
content = self.build_user_content(current_message, image_paths=media)
blocks = list(runtime_context_blocks or ()) if current_role == "user" else []
merged, runtime_context_meta = append_runtime_context(content, blocks)
current: dict[str, Any] = {"role": current_role, "content": merged}
if current_role == "user" and runtime_context_meta is not None:
current["_meta"] = {
RUNTIME_CONTEXT_MESSAGE_META: runtime_context_meta,
}
return current
def build_user_content(
self,
text: str,
+131 -8
View File
@@ -9,6 +9,7 @@ import dataclasses
import inspect
import os
import time
import weakref
from collections.abc import Coroutine, Iterable, Mapping
from contextlib import AbstractContextManager, ExitStack, nullcontext, suppress
from dataclasses import dataclass, field
@@ -48,7 +49,7 @@ from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeEventBus
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
from nanobot.providers.base import LLMProvider
from nanobot.providers.base import LLMProvider, ProviderConversationState
from nanobot.providers.factory import ProviderSnapshot
from nanobot.runtime_context import (
RUNTIME_CONTEXT_HISTORY_META,
@@ -105,6 +106,7 @@ if TYPE_CHECKING:
from nanobot.triggers.local_store import LocalTriggerStore
_T = TypeVar("_T")
_SUBAGENT_PROVIDER_TASK_META = "subagent_provider_task_id"
class TurnKind(Enum):
@@ -125,6 +127,7 @@ class TurnContext:
history: list[dict[str, Any]] = field(default_factory=list)
initial_messages: list[dict[str, Any]] = field(default_factory=list)
provider_state: ProviderConversationState | None = field(default=None, repr=False)
request_context: RequestContext | None = None
runtime_context_blocks: list[RuntimeContextBlock] = field(default_factory=list)
attributes: dict[str, Any] = field(default_factory=dict)
@@ -242,6 +245,8 @@ class AgentLoop:
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
_PENDING_USER_TURN_KEY = "pending_user_turn"
_PROVIDER_STATE_CHECKPOINT_VERSION_KEY = "provider_state_checkpoint_version"
_PROVIDER_STATE_CHECKPOINT_VERSION = "v1"
def __init__(
self,
@@ -394,7 +399,9 @@ class AgentLoop:
self._runtime_context_providers: list[RuntimeContextProvider] = []
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
self._background_tasks: set[asyncio.Task[Any]] = set()
self._session_locks: dict[str, asyncio.Lock] = {}
self._session_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
weakref.WeakValueDictionary()
)
# Per-session pending queues for mid-turn message injection.
# When a session has an active task, new messages for that session
# are routed here instead of creating a new task.
@@ -854,6 +861,7 @@ class AgentLoop:
turn_scopes: list[AbstractContextManager[Any]] | None = None,
tools: ToolRegistry | None = None,
request_context: RequestContext | None = None,
provider_state: ProviderConversationState | None = None,
) -> tuple[str | None, list[str], list[dict[str, Any]], str, bool]:
"""Run the agent iteration loop.
@@ -869,7 +877,18 @@ class AgentLoop:
async def _checkpoint(payload: dict[str, Any]) -> None:
if session is None:
return
self._set_runtime_checkpoint(session, payload)
public_payload = dict(payload)
private_state = public_payload.pop("provider_state", None)
public_payload.pop(self._PROVIDER_STATE_CHECKPOINT_VERSION_KEY, None)
if "provider_state" in payload and (
private_state is None
or isinstance(private_state, ProviderConversationState)
):
session.provider_state = private_state
public_payload[self._PROVIDER_STATE_CHECKPOINT_VERSION_KEY] = (
self._PROVIDER_STATE_CHECKPOINT_VERSION
)
self._set_runtime_checkpoint(session, public_payload)
async def _drain_pending(*, limit: int = _MAX_INJECTIONS_PER_TURN) -> list[dict[str, Any]]:
"""Drain follow-up messages from the pending queue.
@@ -1067,6 +1086,7 @@ class AgentLoop:
session_metadata=session_metadata,
message_metadata=metadata,
),
provider_state=provider_state,
))
finally:
turn_scope_stack.close()
@@ -1074,6 +1094,8 @@ class AgentLoop:
reset_request_context(request_token)
reset_file_states(file_state_token)
self._last_usage = result.usage
if session is not None and not ephemeral:
session.provider_state = result.provider_state
if result.stop_reason == "max_iterations":
logger.warning("Max iterations ({}) reached", self.max_iterations)
should_stream = turn_continuation.should_stream_budget_response(
@@ -1206,7 +1228,7 @@ class AgentLoop:
session_key = self._effective_session_key(msg)
if session_key != msg.session_key:
msg = dataclasses.replace(msg, session_key_override=session_key)
lock = self._session_locks.setdefault(session_key, asyncio.Lock())
lock = self._get_session_lock(session_key)
gate = self._concurrency_gate or nullcontext()
delivery = self.turn_delivery_factory.unrouted(msg, session_key)
@@ -1657,14 +1679,24 @@ class AgentLoop:
"extend_to_user": is_subagent,
}
ctx.history = session.get_history(**_hist_kwargs)
stored_state = session.provider_state
subagent_followup_persisted = False
if is_subagent:
# Keep the durable internal delivery as an assistant record, but
# present this completion to the model as fresh follow-up input.
# Providers without assistant-prefill support drop trailing
# assistant messages, so using the persisted record as the current
# prompt would hide an independently dispatched subagent result.
if self._persist_subagent_followup(session, ctx.msg):
subagent_followup_persisted = self._persist_subagent_followup(
session,
ctx.msg,
)
if subagent_followup_persisted:
logger.debug("Subagent result persisted for session {}", ctx.session_key)
# Establish a durable, replay-safe baseline before any fallible
# provider compatibility or prompt assembly work. A compatible
# staged state replaces this in a second atomic save below.
session.provider_state = None
self.sessions.save(session)
ctx.input_persisted_early = True
ctx.delivery.record_runtime(runtime)
@@ -1672,13 +1704,65 @@ class AgentLoop:
ctx.request_context = self._request_context_for_turn(ctx)
if ctx.kind is TurnKind.USER:
ctx.runtime_context_blocks = await self._resolve_runtime_context_for_turn(ctx)
ctx.initial_messages = self._build_initial_messages(ctx)
staged_provider_state = False
if stored_state is not None and runtime.provider.can_resume_conversation_state(
stored_state,
runtime.model,
):
current_provider_message = self.context.build_current_message(
ctx.msg.content,
media=ctx.msg.media if ctx.kind is TurnKind.USER and ctx.msg.media else None,
runtime_context_blocks=ctx.runtime_context_blocks,
)
task_id = ctx.msg.metadata.get("subagent_task_id") if is_subagent else None
already_staged = False
if isinstance(task_id, str) and task_id:
internal_meta = current_provider_message.get("_meta")
current_provider_message["_meta"] = {
**(
cast(dict[str, Any], internal_meta)
if isinstance(internal_meta, dict)
else {}
),
_SUBAGENT_PROVIDER_TASK_META: task_id,
}
already_staged = any(
isinstance(message.get("_meta"), dict)
and cast(dict[str, Any], message["_meta"]).get(
_SUBAGENT_PROVIDER_TASK_META
)
== task_id
for message in stored_state.pending_messages
)
ctx.provider_state = (
stored_state
if already_staged
else stored_state.with_pending_messages([
*stored_state.pending_messages,
current_provider_message,
])
)
if (
not ctx.ephemeral
and (ctx.kind is TurnKind.USER or subagent_followup_persisted)
):
session.provider_state = ctx.provider_state
staged_provider_state = True
elif stored_state is not None:
session.provider_state = None
if ctx.kind is TurnKind.USER:
ctx.input_persisted_early = self._persist_user_message_early(
ctx.msg,
session,
runtime_context_blocks=ctx.runtime_context_blocks,
)
if staged_provider_state and not ctx.input_persisted_early:
session.provider_state = stored_state
elif subagent_followup_persisted and staged_provider_state:
# Upgrade the replay-safe baseline to the resumable state before
# prompt assembly and the first model checkpoint.
self.sessions.save(session)
ctx.initial_messages = self._build_initial_messages(ctx)
if ctx.on_progress is None:
ctx.on_progress = ctx.delivery.progress_callback()
@@ -1712,6 +1796,7 @@ class AgentLoop:
turn_scopes=ctx.turn_scopes,
tools=ctx.tools,
request_context=ctx.request_context,
provider_state=ctx.provider_state,
)
final_content, _, all_msgs, stop_reason, had_injections = result
ctx.final_content = final_content
@@ -2049,7 +2134,36 @@ class AgentLoop:
):
overlap = size
break
session.messages.extend(restored_messages[overlap:])
appended_messages = restored_messages[overlap:]
session.messages.extend(appended_messages)
assistant_message_data = (
cast(dict[str, Any], assistant_message)
if isinstance(assistant_message, dict)
else None
)
provider_state_is_synchronized = (
checkpoint_data.get(self._PROVIDER_STATE_CHECKPOINT_VERSION_KEY)
== self._PROVIDER_STATE_CHECKPOINT_VERSION
)
phase = checkpoint_data.get("phase")
exact_final_response = (
phase == "final_response"
and assistant_message_data is not None
and assistant_message_data.get("role") == "assistant"
and not bool(checkpoint_data.get("completed_tool_results"))
and not bool(checkpoint_data.get("pending_tool_calls"))
)
exact_completed_tools = (
phase == "tools_completed"
and assistant_message_data is not None
and assistant_message_data.get("role") == "assistant"
and not bool(checkpoint_data.get("pending_tool_calls"))
)
if not (
provider_state_is_synchronized
and (exact_final_response or exact_completed_tools)
):
session.provider_state = None
self._clear_pending_user_turn(session)
self._clear_runtime_checkpoint(session)
@@ -2070,6 +2184,7 @@ class AgentLoop:
"timestamp": datetime.now().isoformat(),
}
)
session.provider_state = None
session.updated_at = datetime.now()
self._clear_pending_user_turn(session)
@@ -2108,7 +2223,7 @@ class AgentLoop:
content=content, media=media or [], metadata=metadata,
)
# Share the dispatch lock so direct calls serialize with bus turns.
lock = self._session_locks.setdefault(session_key, asyncio.Lock())
lock = self._get_session_lock(session_key)
try:
async with lock:
kwargs: dict[str, Any] = {
@@ -2139,3 +2254,11 @@ class AgentLoop:
finally:
await self.runtime_event_publisher.run_status_changed(msg, session_key, "idle")
self.runtime_event_publisher.clear_turn(session_key)
def _get_session_lock(self, session_key: str) -> asyncio.Lock:
"""Return the shared lock while allowing idle session entries to expire."""
lock = self._session_locks.get(session_key)
if lock is None:
lock = asyncio.Lock()
self._session_locks[session_key] = lock
return lock
+3
View File
@@ -931,6 +931,7 @@ class Consolidator:
session_key=session.key,
)
session.last_consolidated = end_idx
session.provider_state = None
self.sessions.save(session)
return summary
@@ -1136,6 +1137,7 @@ class Consolidator:
if summary:
last_summary = summary
session.last_consolidated = end_idx
session.provider_state = None
self.sessions.save(session)
if not summary:
# LLM is degraded — stop hammering it this call;
@@ -1205,6 +1207,7 @@ class Consolidator:
# Preserve history and advance only the replay boundary.
session.last_consolidated = len(session.messages) - len(visible_suffix)
session.provider_state = None
self.sessions.save(session)
logger.info(
+167 -29
View File
@@ -19,7 +19,17 @@ from nanobot.agent.context_governance import (
)
from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext
from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.providers.base import (
LLMProvider,
LLMResponse,
ProviderCallContext,
ProviderConversationState,
ToolCallRequest,
)
from nanobot.providers.conversation_state import (
ProviderConversationStateController,
allows_conversation_message_merge,
)
from nanobot.runtime_context import (
RUNTIME_CONTEXT_MESSAGE_META,
detach_runtime_context,
@@ -104,6 +114,7 @@ class AgentRunSpec:
goal_active_predicate: Callable[[], bool] | None = None
goal_continue_message: GoalContinueMessage | None = None
finalize_on_max_iterations: bool = True
provider_state: ProviderConversationState | None = None
@dataclass(slots=True)
@@ -120,6 +131,7 @@ class AgentRunResult:
had_injections: bool = False
# Terminal tail to emit when the preceding final-content prefix was already streamed.
pending_stream_content: str | None = None
provider_state: ProviderConversationState | None = field(default=None, repr=False)
class AgentRunner:
@@ -161,6 +173,7 @@ class AgentRunner:
and messages[-1].get("role") == "user"
and not is_hidden_history_message(injection)
and not is_hidden_history_message(messages[-1])
and allows_conversation_message_merge(messages[-1])
):
merged = dict(messages[-1])
left_meta = merged.get("_meta")
@@ -231,6 +244,7 @@ class AgentRunner:
assistant_message: dict[str, Any] | None,
injection_cycles: int,
*,
conversation_state: ProviderConversationStateController | None = None,
phase: str = "after error",
iteration: int | None = None,
allow_goal_continue: bool = False,
@@ -258,16 +272,21 @@ class AgentRunner:
if assistant_message is not None:
messages.append(assistant_message)
if iteration is not None:
checkpoint: dict[str, Any] = {
"phase": "final_response",
"iteration": iteration,
"model": spec.runtime.model,
"assistant_message": assistant_message,
"completed_tool_results": [],
"pending_tool_calls": [],
}
if conversation_state is not None:
checkpoint["provider_state"] = conversation_state.checkpoint(
messages
)
await self._emit_checkpoint(
spec,
{
"phase": "final_response",
"iteration": iteration,
"model": spec.runtime.model,
"assistant_message": assistant_message,
"completed_tool_results": [],
"pending_tool_calls": [],
},
checkpoint,
)
self._append_injected_messages(messages, injections)
if real_injection:
@@ -420,6 +439,12 @@ class AgentRunner:
injection_cycles = 0
compacted_tool_call_ids: set[str] = set()
pending_stream_content: str | None = None
conversation_state = ProviderConversationStateController(
provider=spec.runtime.provider,
model=spec.runtime.model,
messages=messages,
state=spec.provider_state,
)
governance_config = ContextGovernanceConfig(
provider=spec.runtime.provider,
model=spec.runtime.model,
@@ -450,7 +475,20 @@ class AgentRunner:
session_key=spec.session_key,
)
await hook.before_iteration(context)
response = await self._request_model(spec, messages_for_model, hook, context)
provider_context = conversation_state.prepare_request(
messages,
context_window_tokens=spec.runtime.context_window_tokens,
model_messages=messages_for_model,
)
response = await self._request_model(
spec,
messages_for_model,
hook,
context,
conversation_state=conversation_state,
provider_context=provider_context,
)
conversation_state.observe_response(response, messages)
context.response = response
context.tool_calls = list(response.tool_calls)
@@ -480,6 +518,10 @@ class AgentRunner:
reasoning_content=response.reasoning_content,
thinking_blocks=response.thinking_blocks,
)
assistant_message = conversation_state.project_response_message(
assistant_message,
response,
)
messages.append(assistant_message)
await self._emit_checkpoint(
spec,
@@ -544,6 +586,15 @@ class AgentRunner:
length_recovery_parts.clear()
continue
break
checkpoint_model_messages = (
self.context_governor.prepare_for_model(
governance_config,
messages,
compacted_tool_call_ids,
)
if response.provider_state is not None
else None
)
await self._emit_checkpoint(
spec,
{
@@ -553,6 +604,10 @@ class AgentRunner:
"assistant_message": assistant_message,
"completed_tool_results": completed_tool_results,
"pending_tool_calls": [],
"provider_state": conversation_state.checkpoint(
messages,
model_messages=checkpoint_model_messages,
),
},
)
empty_content_retries = 0
@@ -575,7 +630,11 @@ class AgentRunner:
)
clean = hook.finalize_content(context, response.content)
if response.finish_reason != "error" and is_blank_text(clean):
if (
response.finish_reason
not in {"error", "length", "refusal", "content_filter"}
and is_blank_text(clean)
):
empty_content_retries += 1
if empty_content_retries < _MAX_EMPTY_RETRIES:
logger.warning(
@@ -598,7 +657,12 @@ class AgentRunner:
if hook.wants_streaming():
await hook.on_stream_end(context, resuming=False)
retry_messages = self._finalization_retry_messages(messages_for_model)
response = await self._request_finalization_retry(spec, messages_for_model)
response = await self._request_finalization_retry(
spec,
messages_for_model,
transcript=messages,
conversation_state=conversation_state,
)
retry_usage = self._usage_or_estimate(spec, retry_messages, response)
self._accumulate_usage(usage, retry_usage)
raw_usage = self._merge_usage(raw_usage, retry_usage)
@@ -608,7 +672,7 @@ class AgentRunner:
original_content = response.content
clean = hook.finalize_content(context, response.content)
if response.finish_reason == "length" and not is_blank_text(clean):
if response.finish_reason == "length":
if len(length_recovery_parts) < _MAX_LENGTH_RECOVERIES:
length_recovery_parts.append(
_restore_outer_whitespace(clean or "", original_content)
@@ -623,10 +687,13 @@ class AgentRunner:
if hook.wants_streaming():
context.stream_continues_current_message = True
await hook.on_stream_end(context, resuming=True)
messages.append(build_assistant_message(
clean,
reasoning_content=response.reasoning_content,
thinking_blocks=response.thinking_blocks,
messages.append(conversation_state.project_response_message(
build_assistant_message(
clean,
reasoning_content=response.reasoning_content,
thinking_blocks=response.thinking_blocks,
),
response,
))
messages.append(build_length_recovery_message(clean or ""))
await hook.after_iteration(context)
@@ -656,15 +723,22 @@ class AgentRunner:
reasoning_content=response.reasoning_content,
thinking_blocks=response.thinking_blocks,
)
assistant_message = conversation_state.project_response_message(
assistant_message,
response,
)
# Check for mid-turn injections BEFORE signaling stream end.
# If injections are found we keep the stream alive (resuming=True)
# so streaming channels don't prematurely finalize the card.
should_continue, injection_cycles = await self._try_drain_injections(
spec, messages, assistant_message, injection_cycles,
conversation_state=conversation_state,
phase="after final response",
iteration=iteration,
allow_goal_continue=True,
allow_goal_continue=(
response.finish_reason not in {"refusal", "content_filter"}
),
)
if should_continue:
had_injections = True
@@ -717,11 +791,17 @@ class AgentRunner:
continue
break
messages.append(assistant_message or build_assistant_message(
clean,
reasoning_content=response.reasoning_content,
thinking_blocks=response.thinking_blocks,
))
messages.append(
assistant_message
or conversation_state.project_response_message(
build_assistant_message(
clean,
reasoning_content=response.reasoning_content,
thinking_blocks=response.thinking_blocks,
),
response,
)
)
await self._emit_checkpoint(
spec,
{
@@ -731,6 +811,7 @@ class AgentRunner:
"assistant_message": messages[-1],
"completed_tool_results": [],
"pending_tool_calls": [],
"provider_state": conversation_state.checkpoint(messages),
},
)
if length_recovery_parts:
@@ -764,6 +845,7 @@ class AgentRunner:
hook,
messages,
usage,
conversation_state,
)
if terminal_content is None:
terminal_content = self._max_iterations_fallback(spec)
@@ -787,6 +869,7 @@ class AgentRunner:
tool_events=tool_events,
had_injections=had_injections,
pending_stream_content=pending_stream_content,
provider_state=conversation_state.finish(messages),
)
def _build_request_kwargs(
@@ -817,6 +900,8 @@ class AgentRunner:
context: AgentHookContext,
*,
malformed_retry: bool = False,
conversation_state: ProviderConversationStateController,
provider_context: ProviderCallContext | None = None,
) -> LLMResponse:
timeout_s: float | None = spec.llm_timeout_s
if timeout_s is None:
@@ -886,6 +971,7 @@ class AgentRunner:
coro = spec.runtime.provider.chat_stream_with_retry(
**kwargs,
provider_context=provider_context,
on_content_delta=_stream,
on_thinking_delta=_thinking,
on_tool_call_delta=_provider_tool_event,
@@ -920,11 +1006,15 @@ class AgentRunner:
coro = spec.runtime.provider.chat_stream_with_retry(
**kwargs,
provider_context=provider_context,
on_content_delta=_stream_progress,
on_tool_call_delta=_provider_tool_event,
)
else:
coro = spec.runtime.provider.chat_with_retry(**kwargs)
coro = spec.runtime.provider.chat_with_retry(
**kwargs,
provider_context=provider_context,
)
# Streaming requests also have provider-level idle timeouts
# (NANOBOT_STREAM_IDLE_TIMEOUT_S), but a stream that keeps producing
@@ -986,6 +1076,10 @@ class AgentRunner:
return await self._request_model(
spec, retry_messages, hook, context,
malformed_retry=True,
conversation_state=conversation_state,
provider_context=conversation_state.independent_request_context(
context_window_tokens=spec.runtime.context_window_tokens,
),
)
if (
all_dropped
@@ -998,7 +1092,13 @@ class AgentRunner:
fallback_messages = self._malformed_tool_call_retry_messages(
messages, response.content,
)
return await self._request_no_tools(spec, fallback_messages)
return await self._request_no_tools(
spec,
fallback_messages,
provider_context=conversation_state.independent_request_context(
context_window_tokens=spec.runtime.context_window_tokens,
),
)
return response
@staticmethod
@@ -1031,6 +1131,10 @@ class AgentRunner:
original_finish_reason,
)
response.tool_calls = valid
# The opaque candidate still contains every raw function_call item.
# Advancing it after dropping even one call would replay an unmatched
# call without a corresponding tool output on the next request.
response.provider_state = None
if not valid:
response.finish_reason = "stop"
return (dropped, not valid, original_finish_reason)
@@ -1060,9 +1164,27 @@ class AgentRunner:
self,
spec: AgentRunSpec,
messages: list[dict[str, Any]],
*,
transcript: list[dict[str, Any]],
conversation_state: ProviderConversationStateController,
) -> LLMResponse:
retry_messages = self._finalization_retry_messages(messages)
return await self._request_no_tools(spec, retry_messages)
provider_context = conversation_state.prepare_request(
transcript,
context_window_tokens=spec.runtime.context_window_tokens,
supplemental_messages=[retry_messages[-1]],
)
response = await self._request_no_tools(
spec,
retry_messages,
provider_context=provider_context,
)
conversation_state.observe_response(
response,
transcript,
adopt_candidate_state=False,
)
return response
@staticmethod
def _finalization_retry_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
@@ -1076,10 +1198,17 @@ class AgentRunner:
hook: AgentHook,
messages: list[dict[str, Any]],
usage: dict[str, int],
conversation_state: ProviderConversationStateController,
) -> str | None:
retry_messages = self._budget_exhausted_finalization_messages(messages)
try:
response = await self._request_no_tools(spec, retry_messages)
response = await self._request_no_tools(
spec,
retry_messages,
provider_context=conversation_state.independent_request_context(
context_window_tokens=spec.runtime.context_window_tokens,
),
)
except Exception:
logger.exception(
"Budget-exhausted finalization failed for {}; using fallback",
@@ -1115,9 +1244,18 @@ class AgentRunner:
self,
spec: AgentRunSpec,
messages: list[dict[str, Any]],
*,
provider_context: ProviderCallContext | None = None,
) -> LLMResponse:
kwargs = self._build_request_kwargs(spec, messages, tools=None)
return await spec.runtime.provider.chat_with_retry(**kwargs)
kwargs = self._build_request_kwargs(
spec,
messages,
tools=None,
)
return await spec.runtime.provider.chat_with_retry(
**kwargs,
provider_context=provider_context,
)
@staticmethod
def _budget_exhausted_finalization_messages(
+92 -27
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import time
import uuid
from collections import deque
from contextlib import suppress
from dataclasses import dataclass
from typing import Any
@@ -51,6 +52,66 @@ class ExecSessionInfo:
owner_session_key: str | None = None
class _BoundedOutputBuffer:
"""Keep the first and most recent characters within a fixed budget."""
def __init__(self, max_chars: int) -> None:
self.max_chars = max_chars
self._content = ""
self._tail: deque[str] = deque()
self._tail_chars = 0
self._total_chars = 0
self._truncated = False
@property
def has_output(self) -> bool:
return self._total_chars > 0
@property
def retained_chars(self) -> int:
return len(self._content) + self._tail_chars
def append(self, text: str) -> None:
if not text:
return
self._total_chars += len(text)
if not self._truncated:
combined = self._content + text
if len(combined) <= self.max_chars:
self._content = combined
return
head_chars = self.max_chars // 2
tail_chars = self.max_chars - head_chars
self._content = combined[:head_chars]
self._tail.append(combined[-tail_chars:])
self._tail_chars = tail_chars
self._truncated = True
return
tail_chars = self.max_chars - len(self._content)
self._tail.append(text)
self._tail_chars += len(text)
while self._tail_chars > tail_chars:
excess = self._tail_chars - tail_chars
first = self._tail[0]
if len(first) <= excess:
self._tail.popleft()
self._tail_chars -= len(first)
else:
self._tail[0] = first[excess:]
self._tail_chars -= excess
def drain(self) -> tuple[str, int]:
output = self._content + "".join(self._tail)
truncated_chars = self._total_chars - len(output)
self._content = ""
self._tail.clear()
self._tail_chars = 0
self._total_chars = 0
self._truncated = False
return output, truncated_chars
class _ExecSession:
def __init__(
self,
@@ -73,30 +134,27 @@ class _ExecSession:
# timeout None/0 means no limit; an infinite deadline is never reached.
self.deadline = time.monotonic() + timeout if timeout else float("inf")
self.last_access = time.monotonic()
self._chunks: list[str] = []
self._stdout = _BoundedOutputBuffer(MAX_OUTPUT_CHARS)
self._stderr = _BoundedOutputBuffer(MAX_OUTPUT_CHARS)
self._lock = asyncio.Lock()
self._timed_out = False
self._stdout_task = asyncio.create_task(self._read_stream(process.stdout, ""))
self._stderr_task = asyncio.create_task(self._read_stream(process.stderr, "STDERR:\n"))
self._stdout_task = asyncio.create_task(self._read_stream(process.stdout, self._stdout))
self._stderr_task = asyncio.create_task(self._read_stream(process.stderr, self._stderr))
async def _read_stream(
self,
stream: asyncio.StreamReader | None,
prefix: str,
buffer: _BoundedOutputBuffer,
) -> None:
if stream is None:
return
first = True
while True:
chunk = await stream.read(4096)
if not chunk:
break
text = chunk.decode("utf-8", errors="replace")
if prefix and first:
text = prefix + text
first = False
async with self._lock:
self._chunks.append(text)
buffer.append(text)
async def write(self, chars: str) -> str | None:
if self.process.returncode is not None:
@@ -157,10 +215,14 @@ class _ExecSession:
await self._wait_for_buffered_output()
async with self._lock:
output = "".join(self._chunks)
self._chunks.clear()
stdout, stdout_truncated = self._stdout.drain()
stderr, stderr_truncated = self._stderr.drain()
output, truncated = _truncate_output(output, max_output_chars)
output_parts = [stdout] if stdout else []
if stderr:
output_parts.append(f"STDERR:\n{stderr}")
output = "\n".join(output_parts)
output, response_truncated = _truncate_output(output, max_output_chars)
return _SessionPoll(
output=output,
done=self.process.returncode is not None,
@@ -169,7 +231,7 @@ class _ExecSession:
timed_out=self._timed_out,
terminated=terminated,
stdin_closed=stdin_closed,
truncated_chars=truncated,
truncated_chars=stdout_truncated + stderr_truncated + response_truncated,
)
async def kill(self) -> None:
@@ -195,7 +257,7 @@ class _ExecSession:
deadline = time.monotonic() + OUTPUT_DRAIN_GRACE_S
while time.monotonic() < deadline:
async with self._lock:
if self._chunks:
if self._stdout.has_output or self._stderr.has_output:
return
await asyncio.sleep(0.01)
@@ -403,20 +465,16 @@ def clamp_session_int(value: int | None, default: int, minimum: int, maximum: in
def _truncate_output(output: str, max_output_chars: int) -> tuple[str, int]:
if len(output) <= max_output_chars:
return output, 0
half = max_output_chars // 2
head_chars = max_output_chars // 2
tail_chars = max_output_chars - head_chars
omitted = len(output) - max_output_chars
return (
output[:half]
+ f"\n\n... ({omitted:,} chars truncated) ...\n\n"
+ output[-half:],
omitted,
)
return output[:head_chars] + output[-tail_chars:], omitted
def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
parts = [poll.output] if poll.output else []
if poll.truncated_chars:
parts.append(f"(output truncated by {poll.truncated_chars:,} chars)")
parts.append(f"({poll.truncated_chars:,} chars truncated from output)")
if poll.timed_out:
parts.append("Error: Command timed out; session was terminated.")
if poll.terminated and not poll.timed_out:
@@ -587,7 +645,9 @@ class WriteStdinTool(Tool):
max_output_chars: int,
) -> str:
deadline = time.monotonic() + (wait_timeout_ms / 1000)
aggregate: list[str] = []
aggregate = _BoundedOutputBuffer(max_output_chars)
upstream_truncated = 0
search_overlap = ""
first = True
poll: _SessionPoll | None = None
@@ -604,15 +664,20 @@ class WriteStdinTool(Tool):
owner_session_key=current_request_session_key(),
)
first = False
upstream_truncated += poll.truncated_chars
if poll.output:
aggregate.append(poll.output)
joined = "".join(aggregate)
if wait_for in joined:
poll.output = joined
searchable = search_overlap + poll.output
if wait_for in searchable:
poll.output, aggregate_truncated = aggregate.drain()
poll.truncated_chars = upstream_truncated + aggregate_truncated
result = format_session_poll(session_id, poll)
return ToolResult.error(result) if poll.timed_out else result
overlap_chars = max(0, len(wait_for) - 1)
search_overlap = searchable[-overlap_chars:] if overlap_chars else ""
if poll.done or remaining_ms <= 0:
poll.output = "".join(aggregate)
poll.output, aggregate_truncated = aggregate.drain()
poll.truncated_chars = upstream_truncated + aggregate_truncated
result = format_session_poll(session_id, poll)
if wait_for not in poll.output:
result += f"\nWait target not observed: {wait_for!r}"
+14 -13
View File
@@ -12,7 +12,7 @@ from contextlib import AsyncExitStack, suppress
from typing import TYPE_CHECKING, Any, Mapping, Protocol, cast
from weakref import WeakKeyDictionary
import httpx2 as httpx
import httpx
from loguru import logger
from nanobot.agent.tools.base import Tool, ToolResult
@@ -25,9 +25,9 @@ from nanobot.bus.events import (
)
from nanobot.bus.queue import MessageBus
from nanobot.security.network import (
Httpx2PinnedDNSAsyncTransport,
PinnedDNSAsyncTransport,
env_proxy_applies_to_url,
httpx2_env_proxy_mounts,
httpx_env_proxy_mounts,
resolve_url_target,
validate_url_target,
)
@@ -194,7 +194,7 @@ def _is_session_terminated(exc: BaseException) -> bool:
messages.append(str(getattr(error, "message", "")))
return any(
marker in message.lower()
for marker in ("session terminated", "session not found", "connection closed")
for marker in ("session terminated", "connection closed")
for message in messages
)
@@ -252,8 +252,8 @@ def _redact_url(url: str) -> str:
def _pinned_transport_kwargs() -> dict[str, Any]:
kwargs: dict[str, Any] = {"transport": Httpx2PinnedDNSAsyncTransport()}
mounts = httpx2_env_proxy_mounts()
kwargs: dict[str, Any] = {"transport": PinnedDNSAsyncTransport()}
mounts = httpx_env_proxy_mounts()
if mounts:
kwargs["mounts"] = mounts
return kwargs
@@ -518,7 +518,7 @@ def _image_block_data_url(block: Any, types: Any) -> str | None:
"""
image_cls = getattr(types, "ImageContent", None)
if image_cls is not None and isinstance(block, image_cls):
mime = getattr(block, "mime_type", None) or "image/png"
mime = getattr(block, "mimeType", None) or "image/png"
return f"data:{mime};base64,{block.data}"
embedded_cls = getattr(types, "EmbeddedResource", None)
@@ -527,7 +527,7 @@ def _image_block_data_url(block: Any, types: Any) -> str | None:
resource = getattr(block, "resource", None)
if blob_cls is not None and isinstance(resource, blob_cls):
blob_resource = cast(Any, resource)
mime = getattr(blob_resource, "mime_type", None) or ""
mime = getattr(blob_resource, "mimeType", None) or ""
if isinstance(mime, str) and mime.startswith("image/"):
return f"data:{mime};base64,{blob_resource.blob}"
return None
@@ -571,7 +571,7 @@ class MCPToolWrapper(_MCPWrapperBase):
self._original_name = tool_def.name
self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_{tool_def.name}")
self._description = tool_def.description or tool_def.name
raw_schema = tool_def.input_schema or {"type": "object", "properties": {}}
raw_schema = tool_def.inputSchema or {"type": "object", "properties": {}}
self._parameters = _normalize_schema_for_openai(raw_schema)
self._tool_timeout = tool_timeout
@@ -650,7 +650,7 @@ class MCPToolWrapper(_MCPWrapperBase):
# Success — extract text and persist any image content as artifacts.
try:
rendered = self._render_call_result(result.content, kwargs)
if getattr(result, "is_error", False):
if getattr(result, "isError", False):
return ToolResult.error(rendered)
return rendered
except Exception as exc:
@@ -876,7 +876,8 @@ class MCPPromptWrapper(_MCPWrapperBase):
return True
async def execute(self, **kwargs: Any) -> str:
from mcp import MCPError, types
from mcp import types
from mcp.shared.exceptions import McpError
retried_transient = False
refreshed_session = False
@@ -896,7 +897,7 @@ class MCPPromptWrapper(_MCPWrapperBase):
raise
logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name)
return "(MCP prompt call was cancelled)"
except MCPError as exc:
except McpError as exc:
if await self._refresh_session_after_termination(
exc,
refreshed_session,
@@ -1061,7 +1062,7 @@ async def connect_mcp_servers(
**_pinned_transport_kwargs(),
)
)
read, write = await server_stack.enter_async_context(
read, write, _ = await server_stack.enter_async_context(
streamable_http_client(cfg.url, http_client=http_client)
)
else:
+9 -1
View File
@@ -248,7 +248,15 @@ class BaseChannel(ABC):
permission_id = authorization_id if authorization_id is not None else sender_id
if not self.is_allowed(permission_id):
if is_dm:
code = generate_code(self.name, str(sender_id))
try:
code = generate_code(self.name, str(sender_id))
except OSError:
# Transient pairing-store I/O failure: skip the pairing
# reply for this message rather than crash the handler.
self.logger.warning(
"Pairing store unavailable; dropping DM from {}", sender_id
)
return
await self.send(
OutboundMessage(
channel=self.name,
+1
View File
@@ -1216,6 +1216,7 @@ class WebSocketChannel(BaseChannel):
body,
metadata=meta,
phase="answer",
include_source=True,
)
raw = json.dumps(body, ensure_ascii=False)
if not conns:
@@ -51,6 +51,7 @@ from nanobot.webui.http_utils import (
)
from nanobot.webui.metadata import (
WEBSOCKET_TURN_OWNER_METADATA_KEY,
WEBUI_MESSAGE_SOURCE_METADATA_KEY,
WEBUI_SYSTEM_COMMAND_TURN_PREFIX,
WEBUI_TURN_METADATA_KEY,
)
@@ -1350,6 +1351,35 @@ async def test_send_delta_emits_delta_and_stream_end() -> None:
assert "text" not in second
@pytest.mark.asyncio
async def test_send_delta_preserves_webui_source_metadata() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-source-stream")
source = {"kind": "cron", "label": "Repo check"}
metadata = {WEBUI_MESSAGE_SOURCE_METADATA_KEY: source}
await channel.send_delta("chat-source-stream", "done", metadata=metadata, stream_id="sid")
await channel.send_delta(
"chat-source-stream",
"",
metadata=metadata,
stream_id="sid",
stream_end=True,
)
first = json.loads(mock_ws.send.call_args_list[0][0][0])
second = json.loads(mock_ws.send.call_args_list[1][0][0])
assert first["event"] == "delta"
assert first["source"] == source
assert second["event"] == "stream_end"
assert second["source"] == source
lines = read_transcript_lines("websocket:chat-source-stream")
assert lines[-2]["source"] == source
assert lines[-1]["source"] == source
@pytest.mark.asyncio
async def test_send_delta_marks_resuming_stream_end() -> None:
bus = MagicMock()
+29 -4
View File
@@ -40,9 +40,15 @@ def _load() -> dict[str, Any]:
data = json.load(f)
except FileNotFoundError:
return {"approved": {}, "pending": {}}
except (json.JSONDecodeError, OSError):
except json.JSONDecodeError:
logger.warning("Corrupted pairing store, resetting")
return {"approved": {}, "pending": {}}
except OSError:
# A transiently locked or busy file is not corruption. Propagate so
# mutating callers fail loudly instead of persisting an empty view
# that would erase every approved sender.
logger.warning("Pairing store temporarily unreadable: {}", path)
raise
if not isinstance(data, dict):
logger.warning("Corrupted pairing store, resetting")
return {"approved": {}, "pending": {}}
@@ -171,7 +177,11 @@ def deny_code(code: str) -> bool:
def is_approved(channel: str, sender_id: str) -> bool:
"""Check whether *sender_id* has been approved on *channel*."""
with _LOCK:
data = _load()
try:
data = _load()
except OSError:
# Fail closed for this check; the store itself stays untouched.
return False
approved: dict[str, set[str]] = data.get("approved", {})
return str(sender_id) in approved.get(channel, set())
@@ -179,7 +189,10 @@ def is_approved(channel: str, sender_id: str) -> bool:
def list_pending() -> list[dict[str, Any]]:
"""Return all non-expired pending pairing requests."""
with _LOCK:
data = _load()
try:
data = _load()
except OSError:
return []
_gc_pending(data)
return [
{"code": code, **info}
@@ -257,7 +270,10 @@ def clear_channel(channel: str) -> dict[str, int]:
def get_approved(channel: str) -> list[str]:
"""Return all approved sender IDs for *channel*."""
with _LOCK:
data = _load()
try:
data = _load()
except OSError:
return []
return sorted(data.get("approved", {}).get(channel, set()))
@@ -283,6 +299,15 @@ def handle_pairing_command(channel: str, subcommand_text: str) -> str:
This is a pure function (no side effects other than store mutations)
so it can be used from both the CLI and the agent CommandRouter.
"""
try:
return _handle_pairing_subcommand(channel, subcommand_text)
except OSError:
# Mutations fail loudly on a transient I/O error instead of lying
# ("invalid code") or silently rewriting the store from an empty view.
return "The pairing store is temporarily unavailable. Please try again."
def _handle_pairing_subcommand(channel: str, subcommand_text: str) -> str:
parts = subcommand_text.split()
sub = parts[0] if parts else "list"
arg = parts[1] if len(parts) > 1 else None
+175 -11
View File
@@ -23,14 +23,26 @@ import uuid
from collections.abc import Awaitable, Callable
from typing import Any, cast
from loguru import logger
from openai import AsyncOpenAI
from nanobot.providers.base import LLMProvider, LLMResponse
from nanobot.providers.base import (
LLMProvider,
LLMResponse,
ProviderCallContext,
ProviderConversationState,
)
from nanobot.providers.openai_responses import (
ResponsesStreamCapture,
build_responses_state,
consume_sdk_stream,
convert_messages,
convert_tools,
is_compaction_compatibility_error,
is_replayable_finish_reason,
parse_response_output,
prepare_responses_input,
resolve_compact_threshold,
responses_state_matches,
)
_AZURE_OPENAI_SCOPE = "https://cognitiveservices.azure.com/.default"
@@ -97,6 +109,7 @@ class AzureOpenAIProvider(LLMProvider):
):
super().__init__(api_key, api_base)
self.default_model = default_model
self._native_compaction_available = True
if not api_base:
raise ValueError("Azure OpenAI api_base is required")
@@ -142,6 +155,25 @@ class AzureOpenAIProvider(LLMProvider):
name = deployment_name.lower()
return not any(token in name for token in ("gpt-5", "o1", "o3", "o4"))
def _responses_state_provider(self) -> str:
return f"azure_openai:{str(self.api_base).rstrip('/')}"
def can_resume_conversation_state(
self,
state: ProviderConversationState,
model: str | None = None,
) -> bool:
return responses_state_matches(
state,
provider=self._responses_state_provider(),
model=model or self.default_model,
)
def supports_native_compaction(self, model: str | None = None) -> bool:
"""Azure's native Responses endpoint accepts context management."""
_ = model
return self._native_compaction_available
def _build_body(
self,
messages: list[dict[str, Any]],
@@ -151,10 +183,26 @@ class AzureOpenAIProvider(LLMProvider):
temperature: float,
reasoning_effort: str | None,
tool_choice: str | dict[str, Any] | None,
provider_context: ProviderCallContext | None = None,
) -> dict[str, Any]:
"""Build the Responses API request body from Chat-Completions-style args."""
deployment = model or self.default_model
instructions, input_items = convert_messages(self._sanitize_empty_content(messages))
sanitized_messages = self._sanitize_empty_content(messages)
sanitized_state = (
provider_context.conversation_state
if provider_context is not None
else None
)
if sanitized_state is not None:
sanitized_state = sanitized_state.with_pending_messages(
self._sanitize_empty_content(sanitized_state.pending_messages)
)
instructions, input_items, replayed = prepare_responses_input(
sanitized_messages,
state=sanitized_state,
provider=self._responses_state_provider(),
model=deployment,
)
body: dict[str, Any] = {
"model": deployment,
@@ -164,13 +212,29 @@ class AzureOpenAIProvider(LLMProvider):
"store": False,
"stream": False,
}
compact_threshold = resolve_compact_threshold(
(
provider_context.context_window_tokens
if provider_context is not None
else None
),
max_tokens,
)
if self.supports_native_compaction(deployment) and compact_threshold is not None:
body["context_management"] = [{
"type": "compaction",
"compact_threshold": compact_threshold,
}]
if self._supports_temperature(deployment, reasoning_effort):
body["temperature"] = temperature
if not self._supports_temperature(deployment, reasoning_effort):
body["include"] = ["reasoning.encrypted_content"]
if reasoning_effort and reasoning_effort.lower() != "none":
body["reasoning"] = {"effort": reasoning_effort}
body["include"] = ["reasoning.encrypted_content"]
if replayed and "gpt-5.6" in deployment.lower():
body.setdefault("reasoning", {})["context"] = "all_turns"
if tools:
body["tools"] = convert_tools(tools)
@@ -178,21 +242,97 @@ class AzureOpenAIProvider(LLMProvider):
return body
async def _create_response_with_compaction_fallback(
self,
body: dict[str, Any],
) -> Any:
"""Retry once without server compaction when Azure rejects the option."""
try:
return cast(Any, await self._client.responses.create(**body))
except Exception as exc:
if (
"context_management" not in body
or not is_compaction_compatibility_error(exc)
):
raise
self._native_compaction_available = False
body.pop("context_management", None)
logger.warning(
"Azure Responses server compaction unsupported; disabled for this provider "
"instance (status={})",
getattr(exc, "status_code", None),
)
return cast(Any, await self._client.responses.create(**body))
@staticmethod
def _handle_error(e: Exception) -> LLMResponse:
response = getattr(e, "response", None)
body = getattr(e, "body", None) or getattr(response, "text", None)
body_text = str(body).strip() if body is not None else ""
msg = f"Error: {body_text[:500]}" if body_text else f"Error calling Azure OpenAI: {e}"
retry_after = LLMProvider._extract_retry_after_from_headers(getattr(response, "headers", None))
headers = getattr(response, "headers", None)
retry_after = LLMProvider._extract_retry_after_from_headers(headers)
if retry_after is None:
retry_after = LLMProvider._extract_retry_after(msg)
return LLMResponse(content=msg, finish_reason="error", retry_after=retry_after)
status_code = getattr(e, "status_code", None)
if status_code is None and response is not None:
status_code = getattr(response, "status_code", None)
error_type, error_code = LLMProvider._extract_error_type_code(body)
should_retry: bool | None = None
if headers is not None:
raw_should_retry = headers.get("x-should-retry")
if isinstance(raw_should_retry, str):
lowered = raw_should_retry.strip().lower()
if lowered == "true":
should_retry = True
elif lowered == "false":
should_retry = False
error_name = type(e).__name__.lower()
error_kind = (
"timeout"
if "timeout" in error_name
else "connection"
if "connection" in error_name
else None
)
return LLMResponse(
content=msg,
finish_reason="error",
retry_after=retry_after,
error_status_code=int(status_code) if status_code is not None else None,
error_kind=error_kind,
error_type=error_type,
error_code=error_code,
error_retry_after_s=retry_after,
error_should_retry=should_retry,
)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
async def chat_with_context(
self,
*,
provider_context: ProviderCallContext,
**kwargs: Any,
) -> LLMResponse:
return await self.chat(
**kwargs,
provider_context=provider_context,
)
async def chat_stream_with_context(
self,
*,
provider_context: ProviderCallContext,
**kwargs: Any,
) -> LLMResponse:
return await self.chat_stream(
**kwargs,
provider_context=provider_context,
)
async def chat(
self,
messages: list[dict[str, Any]],
@@ -202,14 +342,21 @@ class AzureOpenAIProvider(LLMProvider):
temperature: float = 0.7,
reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None,
provider_context: ProviderCallContext | None = None,
) -> LLMResponse:
body = self._build_body(
messages, tools, model, max_tokens, temperature,
reasoning_effort, tool_choice,
provider_context,
)
try:
response = cast(Any, await self._client.responses.create(**body))
return parse_response_output(response)
response = await self._create_response_with_compaction_fallback(body)
return parse_response_output(
response,
state_provider=self._responses_state_provider(),
state_model=str(body["model"]),
state_input_items=cast(list[dict[str, Any]], body["input"]),
)
except Exception as e:
return self._handle_error(e)
@@ -225,26 +372,43 @@ class AzureOpenAIProvider(LLMProvider):
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
provider_context: ProviderCallContext | None = None,
) -> LLMResponse:
_ = on_thinking_delta
body = self._build_body(
messages, tools, model, max_tokens, temperature,
reasoning_effort, tool_choice,
provider_context,
)
body["stream"] = True
try:
stream = cast(Any, await self._client.responses.create(**body))
stream = await self._create_response_with_compaction_fallback(body)
capture = ResponsesStreamCapture()
content, tool_calls, finish_reason, usage, reasoning_content = (
await consume_sdk_stream(stream, on_content_delta, on_tool_call_delta)
await consume_sdk_stream(
stream,
on_content_delta,
on_tool_call_delta,
capture=capture,
)
)
return LLMResponse(
result = LLMResponse(
content=content or None,
tool_calls=tool_calls,
finish_reason=finish_reason,
usage=usage,
reasoning_content=reasoning_content,
)
if capture.completed and is_replayable_finish_reason(finish_reason):
result.provider_state = build_responses_state(
provider=self._responses_state_provider(),
model=str(body["model"]),
input_items=cast(list[dict[str, Any]], body["input"]),
output_items=capture.output_items,
usage=usage,
)
return result
except Exception as e:
return self._handle_error(e)
+201 -8
View File
@@ -1,5 +1,7 @@
"""Base LLM provider interface."""
from __future__ import annotations
import asyncio
import json
import os
@@ -7,6 +9,7 @@ import re
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable
from contextlib import suppress
from copy import deepcopy
from dataclasses import dataclass, field
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
@@ -150,6 +153,104 @@ def tool_arguments_json_for_replay(arguments: Any) -> str:
return json.dumps(tool_arguments_object_for_replay(arguments), ensure_ascii=False)
@dataclass
class ProviderConversationState:
"""Opaque provider-owned continuation state.
``payload`` may contain encrypted reasoning or other provider-private
protocol items. Keep it out of normal logs and public chat history.
``pending_messages`` are Chat-style messages produced after the most
recent provider response and are materialized by the owning provider on
the next request.
"""
kind: str
provider: str
model: str
version: int
payload: dict[str, Any] = field(default_factory=dict, repr=False)
pending_messages: list[dict[str, Any]] = field(default_factory=list, repr=False)
def with_pending_messages(
self,
messages: list[dict[str, Any]],
) -> ProviderConversationState:
"""Return a state copy with an isolated pending-message list."""
return ProviderConversationState(
kind=self.kind,
provider=self.provider,
model=self.model,
version=self.version,
payload=self.payload,
pending_messages=deepcopy(messages),
)
def to_private_record(self) -> dict[str, Any]:
"""Serialize for the private session sidecar, never for public history."""
return {
"kind": self.kind,
"provider": self.provider,
"model": self.model,
"version": self.version,
"payload": deepcopy(self.payload),
"pending_messages": deepcopy(self.pending_messages),
}
@classmethod
def from_private_record(
cls,
value: object,
) -> ProviderConversationState | None:
"""Validate and deserialize a private session-sidecar value."""
if not isinstance(value, dict):
return None
data = cast(dict[str, Any], value)
kind = data.get("kind")
provider = data.get("provider")
model = data.get("model")
version = data.get("version")
payload = data.get("payload")
pending = data.get("pending_messages", [])
if (
not isinstance(kind, str)
or not kind
or not isinstance(provider, str)
or not provider
or not isinstance(model, str)
or not model
or isinstance(version, bool)
or not isinstance(version, int)
or not isinstance(payload, dict)
or not isinstance(pending, list)
or any(
not isinstance(message, dict)
for message in cast(list[object], pending)
)
):
return None
return cls(
kind=kind,
provider=provider,
model=model,
version=version,
payload=deepcopy(cast(dict[str, Any], payload)),
pending_messages=deepcopy(cast(list[dict[str, Any]], pending)),
)
@dataclass(frozen=True)
class ProviderCallContext:
"""Optional provider-owned continuation data for one model request.
The regular ``chat`` contract stays provider-agnostic. Responses-capable
providers consume this context through the opt-in ``chat_with_context``
hooks, while every other provider inherits the context-free delegation.
"""
conversation_state: ProviderConversationState | None = field(default=None, repr=False)
context_window_tokens: int | None = None
@dataclass
class LLMResponse:
"""Response from an LLM provider."""
@@ -160,6 +261,10 @@ class LLMResponse:
retry_after: float | None = None # Provider supplied retry wait in seconds.
reasoning_content: str | None = None # Kimi, DeepSeek-R1, MiMo etc.
thinking_blocks: list[dict[str, Any]] | None = None # Anthropic extended thinking
provider_state: ProviderConversationState | None = field(default=None, repr=False)
# Routing wrappers may preserve or discard an incoming provider-owned
# continuation independently of the final fallback error's retry policy.
preserve_provider_state_on_error: bool | None = field(default=None, repr=False)
# Structured error metadata used by retry policy when finish_reason == "error".
error_status_code: int | None = None
error_kind: str | None = None # e.g. "timeout", "connection"
@@ -274,6 +379,18 @@ class LLMProvider(ABC):
self.api_base = api_base
self.generation: GenerationSettings = GenerationSettings()
def can_resume_conversation_state(
self,
state: ProviderConversationState,
model: str | None = None,
) -> bool:
"""Whether this provider can safely consume an opaque saved state."""
return False
def supports_native_compaction(self, model: str | None = None) -> bool:
"""Whether requests may include provider-native context compaction."""
return False
@staticmethod
def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Sanitize message content: fix empty blocks, strip internal _meta fields.
@@ -416,7 +533,7 @@ class LLMProvider(ABC):
return any(marker in err for marker in cls._TRANSIENT_ERROR_MARKERS)
@classmethod
def _is_transient_response(cls, response: LLMResponse) -> bool:
def is_transient_response(cls, response: LLMResponse) -> bool:
"""Prefer structured error metadata, fallback to text markers for legacy providers."""
if response.error_should_retry is not None:
return bool(response.error_should_retry)
@@ -607,6 +724,21 @@ class LLMProvider(ABC):
result.append(msg)
return result if found else None
@staticmethod
def _contains_image_content(value: object) -> bool:
"""Return whether a JSON-like provider payload contains an input image."""
if isinstance(value, dict):
mapping = cast(dict[str, object], value)
if mapping.get("type") in {"image_url", "input_image"}:
return True
return any(LLMProvider._contains_image_content(item) for item in mapping.values())
if isinstance(value, list):
return any(
LLMProvider._contains_image_content(item)
for item in cast(list[object], value)
)
return False
@staticmethod
def _strip_image_content_inplace(messages: list[dict[str, Any]]) -> bool:
"""Replace image_url blocks with text placeholder *in-place*.
@@ -633,6 +765,12 @@ class LLMProvider(ABC):
async def _safe_chat(self, **kwargs: Any) -> LLMResponse:
"""Call chat() and convert unexpected exceptions to error responses."""
try:
provider_context = kwargs.pop("provider_context", None)
if isinstance(provider_context, ProviderCallContext):
return await self.chat_with_context(
provider_context=provider_context,
**kwargs,
)
return await self.chat(**kwargs)
except asyncio.CancelledError:
raise
@@ -666,17 +804,47 @@ class LLMProvider(ABC):
"""
_ = on_thinking_delta, on_tool_call_delta
response = await self.chat(
messages=messages, tools=tools, model=model,
max_tokens=max_tokens, temperature=temperature,
reasoning_effort=reasoning_effort, tool_choice=tool_choice,
messages=messages,
tools=tools,
model=model,
max_tokens=max_tokens,
temperature=temperature,
reasoning_effort=reasoning_effort,
tool_choice=tool_choice,
)
if on_content_delta and response.content:
await on_content_delta(response.content)
return response
async def chat_with_context(
self,
*,
provider_context: ProviderCallContext,
**kwargs: Any,
) -> LLMResponse:
"""Opt-in continuation hook; ordinary providers delegate to ``chat``."""
_ = provider_context
return await self.chat(**kwargs)
async def chat_stream_with_context(
self,
*,
provider_context: ProviderCallContext,
**kwargs: Any,
) -> LLMResponse:
"""Streaming continuation hook with a context-free default."""
_ = provider_context
return await self.chat_stream(**kwargs)
async def _safe_chat_stream(self, **kwargs: Any) -> LLMResponse:
"""Call chat_stream() and convert unexpected exceptions to error responses."""
try:
provider_context = kwargs.pop("provider_context", None)
if isinstance(provider_context, ProviderCallContext):
return await self.chat_stream_with_context(
provider_context=provider_context,
**kwargs,
)
return await self.chat_stream(**kwargs)
except asyncio.CancelledError:
raise
@@ -698,6 +866,7 @@ class LLMProvider(ABC):
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
retry_mode: str = "standard",
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
provider_context: ProviderCallContext | None = None,
) -> LLMResponse:
"""Call chat_stream() with retry on transient provider failures."""
if max_tokens is self._SENTINEL or max_tokens is None:
@@ -730,6 +899,8 @@ class LLMProvider(ABC):
on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta,
)
if provider_context is not None:
kw["provider_context"] = provider_context
if on_stream_recover and getattr(self, "supports_stream_recover_callback", False):
kw["on_stream_recover"] = _recover_stream
return await self._run_with_retry(
@@ -753,6 +924,7 @@ class LLMProvider(ABC):
tool_choice: str | dict[str, Any] | None = None,
retry_mode: str = "standard",
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
provider_context: ProviderCallContext | None = None,
) -> LLMResponse:
"""Call chat() with retry on transient provider failures.
@@ -775,6 +947,8 @@ class LLMProvider(ABC):
max_tokens=max_tokens, temperature=temperature,
reasoning_effort=reasoning_effort, tool_choice=tool_choice,
)
if provider_context is not None:
kw["provider_context"] = provider_context
return await self._run_with_retry(
self._safe_chat,
kw,
@@ -932,14 +1106,33 @@ class LLMProvider(ABC):
last_error_key = error_key
identical_error_count = 1 if error_key else 0
if not self._is_transient_response(response):
stripped = self._strip_image_content(original_messages)
if stripped is not None and stripped != kw["messages"]:
if not self.is_transient_response(response):
stripped = self._strip_image_content(kw["messages"])
provider_context = kw.get("provider_context")
stripped_context: ProviderCallContext | None = None
if isinstance(provider_context, ProviderCallContext):
state = provider_context.conversation_state
if state is not None and (
stripped is not None
or self._strip_image_content(state.pending_messages) is not None
or self._contains_image_content(state.payload)
):
# Provider-owned payloads may retain earlier input_image items.
# Rebuild from the stripped public transcript for this retry.
stripped_context = ProviderCallContext(
context_window_tokens=(
provider_context.context_window_tokens
),
)
if stripped is not None or stripped_context is not None:
logger.warning(
"Non-transient LLM error with image content, retrying without images"
)
retry_kw = dict(kw)
retry_kw["messages"] = stripped
if stripped is not None:
retry_kw["messages"] = stripped
if stripped_context is not None:
retry_kw["provider_context"] = stripped_context
result = await call(**retry_kw)
# Permanently strip images from the original messages so
# subsequent iterations do not repeat the error-retry cycle.
+262
View File
@@ -0,0 +1,262 @@
"""Provider-owned conversation-state lifecycle coordination."""
from __future__ import annotations
from copy import deepcopy
from typing import Any, cast
from nanobot.providers.base import (
LLMProvider,
LLMResponse,
ProviderCallContext,
ProviderConversationState,
)
_PROVIDER_STATE_OUTPUT_META = "provider_state_output"
_PROVIDER_STATE_BOUNDARY_META = "provider_state_boundary"
def allows_conversation_message_merge(message: dict[str, Any]) -> bool:
"""Return whether new same-role input may merge into *message*."""
internal_meta = cast(object, message.get("_meta"))
return not (
isinstance(internal_meta, dict)
and cast(dict[str, Any], internal_meta).get(
_PROVIDER_STATE_BOUNDARY_META
) is True
)
class ProviderConversationStateController:
"""Keep provider conversation-state semantics outside the agent runner.
The runner owns the tool loop and reports lifecycle events here. This
controller owns capability checks, transcript deltas, response projections,
retry transitions, and durable snapshots for provider-private state.
"""
def __init__(
self,
*,
provider: LLMProvider,
model: str | None,
messages: list[dict[str, Any]],
state: ProviderConversationState | None = None,
) -> None:
self._provider = provider
self._model = model
self._state = (
state
if state is not None
and provider.can_resume_conversation_state(state, model)
else None
)
self._boundary = len(messages)
self._request_messages: list[dict[str, Any]] = []
def independent_request_context(
self,
*,
context_window_tokens: int | None,
) -> ProviderCallContext | None:
"""Return typed provider context for a request that does not resume state."""
if context_window_tokens is None:
return None
return ProviderCallContext(context_window_tokens=context_window_tokens)
def prepare_request(
self,
messages: list[dict[str, Any]],
*,
context_window_tokens: int | None,
model_messages: list[dict[str, Any]] | None = None,
supplemental_messages: list[dict[str, Any]] | None = None,
) -> ProviderCallContext | None:
"""Build typed context for the next request and remember its durable delta."""
independent_context = self.independent_request_context(
context_window_tokens=context_window_tokens,
)
if self._state is None:
self._request_messages = []
return independent_context
if not self._provider.can_resume_conversation_state(
self._state,
self._model,
):
self._state = None
self._request_messages = []
return independent_context
durable_messages = self._messages_after_boundary(messages)
governed_messages = (
self._model_messages_after_boundary(model_messages)
if model_messages is not None and durable_messages
else None
)
request_messages = (
governed_messages
if governed_messages is not None
else durable_messages
)
supplemental = deepcopy(supplemental_messages or [])
self._request_messages = deepcopy(request_messages)
request_state = self._state.with_pending_messages([
*self._state.pending_messages,
*request_messages,
*supplemental,
])
return ProviderCallContext(
conversation_state=request_state,
context_window_tokens=(
independent_context.context_window_tokens
if independent_context is not None
else None
),
)
def observe_response(
self,
response: LLMResponse,
messages: list[dict[str, Any]],
*,
adopt_candidate_state: bool = True,
) -> None:
"""Advance, preserve, or discard state after one provider response."""
candidate = response.provider_state if adopt_candidate_state else None
candidate_is_replayable = response.finish_reason in {
"stop",
"tool_calls",
"function_call",
}
if (
candidate is not None
and candidate_is_replayable
and self._provider.can_resume_conversation_state(
candidate,
self._model,
)
):
self._state = candidate
self._boundary = len(messages)
self._seal_boundary(messages)
elif response.finish_reason == "error" and (
response.preserve_provider_state_on_error is True
or (
response.preserve_provider_state_on_error is None
and LLMProvider.is_transient_response(response)
)
):
if self._state is not None and self._request_messages:
self._state = self._state.with_pending_messages([
*self._state.pending_messages,
*self._request_messages,
])
self._boundary = len(messages)
else:
self._state = None
self._boundary = len(messages)
self._request_messages = []
@staticmethod
def project_response_message(
message: dict[str, Any],
response: LLMResponse,
) -> dict[str, Any]:
"""Mark a Chat projection already represented by provider output."""
if response.provider_state is None:
return message
internal_meta = dict(message.get("_meta") or {})
internal_meta[_PROVIDER_STATE_OUTPUT_META] = True
message["_meta"] = internal_meta
return message
def checkpoint(
self,
messages: list[dict[str, Any]],
*,
model_messages: list[dict[str, Any]] | None = None,
) -> ProviderConversationState | None:
"""Return a durable state snapshot without changing live state."""
if self._state is None:
return None
durable_messages = self._messages_after_boundary(messages)
governed_messages = (
self._model_messages_after_boundary(model_messages)
if model_messages is not None and durable_messages
else None
)
pending_messages = (
governed_messages
if governed_messages is not None
else durable_messages
)
return self._state.with_pending_messages([
*self._state.pending_messages,
*pending_messages,
])
def finish(
self,
messages: list[dict[str, Any]],
) -> ProviderConversationState | None:
"""Return the final durable state after all runner messages are known."""
self._state = self.checkpoint(messages)
return self._state
def _messages_after_boundary(
self,
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
pending: list[dict[str, Any]] = []
for message in messages[self._boundary:]:
internal_meta = cast(object, message.get("_meta"))
if (
isinstance(internal_meta, dict)
and cast(dict[str, Any], internal_meta).get(
_PROVIDER_STATE_OUTPUT_META
) is True
):
continue
pending.append(deepcopy(message))
return pending
@staticmethod
def _model_messages_after_boundary(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]] | None:
"""Return the governed delta after the latest provider-owned boundary."""
boundary = None
for idx in range(len(messages) - 1, -1, -1):
internal_meta = cast(object, messages[idx].get("_meta"))
if (
isinstance(internal_meta, dict)
and cast(dict[str, Any], internal_meta).get(
_PROVIDER_STATE_BOUNDARY_META
) is True
):
boundary = idx
break
if boundary is None:
return None
pending: list[dict[str, Any]] = []
for message in messages[boundary + 1:]:
internal_meta = cast(object, message.get("_meta"))
if (
isinstance(internal_meta, dict)
and cast(dict[str, Any], internal_meta).get(
_PROVIDER_STATE_OUTPUT_META
) is True
):
continue
pending.append(deepcopy(message))
return pending
@staticmethod
def _seal_boundary(messages: list[dict[str, Any]]) -> None:
"""Prevent later same-role injection merging across a state boundary."""
if not messages:
return
internal_meta = dict(messages[-1].get("_meta") or {})
internal_meta[_PROVIDER_STATE_BOUNDARY_META] = True
messages[-1]["_meta"] = internal_meta
+1
View File
@@ -261,6 +261,7 @@ def make_provider(
primary=provider,
fallback_presets=fallback_presets,
provider_factory=lambda fb: _make_provider_core(config, preset=fb),
primary_context_window_tokens=resolved.context_window_tokens,
)
return provider
+113 -2
View File
@@ -6,11 +6,18 @@ from __future__ import annotations
import time
from collections.abc import Awaitable, Callable
from dataclasses import replace
from typing import Any
from loguru import logger
from nanobot.providers.base import GenerationSettings, LLMProvider, LLMResponse
from nanobot.providers.base import (
GenerationSettings,
LLMProvider,
LLMResponse,
ProviderCallContext,
ProviderConversationState,
)
# Circuit breaker tuned to match OpenAICompatProvider's Responses API breaker.
_PRIMARY_FAILURE_THRESHOLD = 3
@@ -113,11 +120,13 @@ class FallbackProvider(LLMProvider):
fallback_presets: list[Any],
provider_factory: Callable[[Any], LLMProvider],
fallback_model_observer: FallbackModelObserver | None = None,
primary_context_window_tokens: int | None = None,
):
self._primary = primary
self._fallback_presets = list(fallback_presets)
self._provider_factory = provider_factory
self._fallback_model_observer = fallback_model_observer
self._primary_context_window_tokens = primary_context_window_tokens
self._has_fallbacks = bool(fallback_presets)
self._primary_failures = 0
self._primary_tripped_at: float | None = None
@@ -141,6 +150,33 @@ class FallbackProvider(LLMProvider):
def supports_progress_deltas(self) -> bool:
return bool(getattr(self._primary, "supports_progress_deltas", False))
def can_resume_conversation_state(
self,
state: ProviderConversationState,
model: str | None = None,
) -> bool:
return self._primary.can_resume_conversation_state(state, model)
def supports_native_compaction(self, model: str | None = None) -> bool:
return self._primary.supports_native_compaction(model)
def _primary_call_context(
self,
provider_context: ProviderCallContext,
model: str | None,
) -> ProviderCallContext:
context_window_tokens = (
self._primary_context_window_tokens
if self._primary_context_window_tokens is not None
else provider_context.context_window_tokens
)
if not self._primary.supports_native_compaction(model):
context_window_tokens = None
return ProviderCallContext(
conversation_state=provider_context.conversation_state,
context_window_tokens=context_window_tokens,
)
def _primary_available(self) -> bool:
"""Return True if the primary provider is not currently tripped."""
if self._primary_tripped_at is None:
@@ -157,6 +193,25 @@ class FallbackProvider(LLMProvider):
lambda p, kw: p.chat(**kw), kwargs, has_streamed=None
)
async def chat_with_context(
self,
*,
provider_context: ProviderCallContext,
**kwargs: Any,
) -> LLMResponse:
call_kwargs: dict[str, Any] = dict(kwargs)
call_kwargs["provider_context"] = self._primary_call_context(
provider_context,
kwargs.get("model"),
)
if not self._has_fallbacks:
return await self._primary.chat_with_context(**call_kwargs)
return await self._try_with_fallback(
lambda p, kw: p.chat_with_context(**kw),
call_kwargs,
has_streamed=None,
)
async def chat_stream(self, **kwargs: Any) -> LLMResponse:
on_stream_recover = kwargs.pop("on_stream_recover", None)
if not self._has_fallbacks:
@@ -179,6 +234,38 @@ class FallbackProvider(LLMProvider):
on_stream_recover=on_stream_recover,
)
async def chat_stream_with_context(
self,
*,
provider_context: ProviderCallContext,
**kwargs: Any,
) -> LLMResponse:
on_stream_recover = kwargs.pop("on_stream_recover", None)
call_kwargs: dict[str, Any] = dict(kwargs)
call_kwargs["provider_context"] = self._primary_call_context(
provider_context,
kwargs.get("model"),
)
if not self._has_fallbacks:
return await self._primary.chat_stream_with_context(**call_kwargs)
has_streamed: list[bool] = [False]
original_delta = call_kwargs.get("on_content_delta")
async def _tracking_delta(text: str) -> None:
if text:
has_streamed[0] = True
if original_delta:
await original_delta(text)
call_kwargs["on_content_delta"] = _tracking_delta
return await self._try_with_fallback(
lambda p, kw: p.chat_stream_with_context(**kw),
call_kwargs,
has_streamed=has_streamed,
on_stream_recover=on_stream_recover,
)
async def _try_with_fallback(
self,
call: Callable[[LLMProvider, dict[str, Any]], Awaitable[LLMResponse]],
@@ -189,6 +276,9 @@ class FallbackProvider(LLMProvider):
primary_model = kwargs.get("model") or self._primary.get_default_model()
primary_was_attempted = False
primary_error = "unknown error"
# A primary error eligible for failover did not return a replacement
# continuation, so the incoming primary state remains reusable.
preserve_primary_state = True
if self._primary_available():
primary_was_attempted = True
@@ -286,6 +376,23 @@ class FallbackProvider(LLMProvider):
"max_tokens": fallback.max_tokens,
"temperature": fallback.temperature,
}
provider_context = fallback_kwargs.get("provider_context")
if isinstance(provider_context, ProviderCallContext):
state = provider_context.conversation_state
if state is not None and not fallback_provider.can_resume_conversation_state(
state,
fallback_model,
):
state = None
context_window_tokens = (
fallback.context_window_tokens
if fallback_provider.supports_native_compaction(fallback_model)
else None
)
fallback_kwargs["provider_context"] = ProviderCallContext(
conversation_state=state,
context_window_tokens=context_window_tokens,
)
if fallback.reasoning_effort is None:
fallback_kwargs.pop("reasoning_effort", None)
else:
@@ -312,11 +419,15 @@ class FallbackProvider(LLMProvider):
)
# Return the last error response we saw (primary or last fallback).
if last_response is not None:
return last_response
return replace(
last_response,
preserve_provider_state_on_error=preserve_primary_state,
)
# Primary was tripped and we have no fallbacks — synthesize an error.
return LLMResponse(
content=f"Primary model '{primary_model}' circuit open and no fallbacks available",
finish_reason="error",
preserve_provider_state_on_error=preserve_primary_state,
)
async def _notify_fallback_model(self, model: str) -> None:
+5 -1
View File
@@ -16,7 +16,7 @@ import httpx
from oauth_cli_kit.models import OAuthToken
from oauth_cli_kit.storage import FileTokenStorage
from nanobot.providers.base import LLMResponse
from nanobot.providers.base import LLMResponse, ProviderCallContext
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
DEFAULT_GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code"
@@ -248,6 +248,7 @@ class GitHubCopilotProvider(OpenAICompatProvider):
temperature: float = 0.7,
reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None,
provider_context: ProviderCallContext | None = None,
) -> LLMResponse:
await self._refresh_client_api_key()
return await super().chat(
@@ -258,6 +259,7 @@ class GitHubCopilotProvider(OpenAICompatProvider):
temperature=temperature,
reasoning_effort=reasoning_effort,
tool_choice=tool_choice,
provider_context=provider_context,
)
async def chat_stream(
@@ -272,6 +274,7 @@ class GitHubCopilotProvider(OpenAICompatProvider):
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
provider_context: ProviderCallContext | None = None,
) -> LLMResponse:
await self._refresh_client_api_key()
return await super().chat_stream(
@@ -285,4 +288,5 @@ class GitHubCopilotProvider(OpenAICompatProvider):
on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta,
provider_context=provider_context,
)
+273 -41
View File
@@ -17,17 +17,27 @@ from oauth_cli_kit import get_token as get_codex_token
from nanobot.providers.base import (
LLMProvider,
LLMResponse,
ToolCallRequest,
ProviderCallContext,
ProviderConversationState,
resolve_stream_idle_timeout_s,
)
from nanobot.providers.openai_responses import (
ResponsesStreamCapture,
build_responses_state,
consume_sse_with_reasoning,
convert_messages,
convert_tools,
is_compaction_compatibility_error,
is_replayable_finish_reason,
prepare_responses_input,
resolve_compact_threshold,
responses_state_context_tokens,
responses_state_items,
responses_state_matches,
)
DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
DEFAULT_ORIGINATOR = "nanobot"
_COMPACTION_RETAINED_CHAR_BUDGET = 256_000
class OpenAICodexProvider(LLMProvider):
@@ -45,21 +55,39 @@ class OpenAICodexProvider(LLMProvider):
self.default_model = default_model
self.proxy = proxy or None
self._extra_body = dict(extra_body or {})
self._native_compaction_available = True
async def _call_codex(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None,
model: str | None,
max_tokens: int,
reasoning_effort: str | None,
tool_choice: str | dict[str, Any] | None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
provider_context: ProviderCallContext | None = None,
) -> LLMResponse:
"""Shared request logic for both chat() and chat_stream()."""
model = model or self.default_model
system_prompt, input_items = convert_messages(messages)
sanitized_messages = self._sanitize_empty_content(messages)
sanitized_state = (
provider_context.conversation_state
if provider_context is not None
else None
)
if sanitized_state is not None:
sanitized_state = sanitized_state.with_pending_messages(
self._sanitize_empty_content(sanitized_state.pending_messages)
)
system_prompt, input_items, replayed = prepare_responses_input(
sanitized_messages,
state=sanitized_state,
provider=self._responses_state_provider(),
model=_strip_model_prefix(model),
)
body: dict[str, Any] = {
"model": _strip_model_prefix(model),
@@ -68,12 +96,15 @@ class OpenAICodexProvider(LLMProvider):
"instructions": system_prompt,
"input": input_items,
"text": {"verbosity": "medium"},
"include": ["reasoning.encrypted_content"],
"prompt_cache_key": _prompt_cache_key(messages[:2]),
"tool_choice": tool_choice or "auto",
"parallel_tool_calls": True,
}
body["include"] = ["reasoning.encrypted_content"]
reasoning_options = _build_reasoning_options(reasoning_effort)
if replayed and "gpt-5.6" in _strip_model_prefix(model).lower():
reasoning_options = dict(reasoning_options or {})
reasoning_options["context"] = "all_turns"
if reasoning_options:
body["reasoning"] = reasoning_options
if tools:
@@ -87,33 +118,90 @@ class OpenAICodexProvider(LLMProvider):
token = await asyncio.to_thread(get_codex_token, proxy=self.proxy)
headers = _build_headers(cast(str, token.account_id), token.access)
stage = "codex_request"
try:
content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex(
DEFAULT_CODEX_URL, headers, body, verify=True,
proxy=self.proxy,
on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta,
)
except Exception as e:
if "CERTIFICATE_VERIFY_FAILED" not in str(e):
raise
logger.warning("SSL verification failed for Codex API; retrying with verify=False")
content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex(
DEFAULT_CODEX_URL, headers, body, verify=False,
proxy=self.proxy,
on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta,
)
return LLMResponse(
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
usage=usage,
reasoning_content=reasoning_content,
async def _send(
request_body: dict[str, Any],
*,
emit_deltas: bool,
) -> LLMResponse:
wire_body = _without_response_item_ids(request_body)
try:
return await _request_codex(
DEFAULT_CODEX_URL,
headers,
wire_body,
verify=True,
proxy=self.proxy,
on_content_delta=on_content_delta if emit_deltas else None,
on_thinking_delta=on_thinking_delta if emit_deltas else None,
on_tool_call_delta=on_tool_call_delta if emit_deltas else None,
)
except Exception as exc:
if "CERTIFICATE_VERIFY_FAILED" not in str(exc):
raise
logger.warning(
"SSL verification failed for Codex API; retrying with verify=False"
)
return await _request_codex(
DEFAULT_CODEX_URL,
headers,
wire_body,
verify=False,
proxy=self.proxy,
on_content_delta=on_content_delta if emit_deltas else None,
on_thinking_delta=on_thinking_delta if emit_deltas else None,
on_tool_call_delta=on_tool_call_delta if emit_deltas else None,
)
compact_threshold = resolve_compact_threshold(
(
provider_context.context_window_tokens
if provider_context is not None
else None
),
max_tokens,
)
if (
self.supports_native_compaction(model)
and replayed
and sanitized_state is not None
and compact_threshold is not None
and responses_state_context_tokens(sanitized_state) >= compact_threshold
):
stage = "codex_compaction"
compact_body = {
**body,
"input": [*input_items, {"type": "compaction_trigger"}],
}
try:
compact_result = await _send(compact_body, emit_deltas=False)
compact_items = (
responses_state_items(compact_result.provider_state)
if compact_result.provider_state is not None
else None
)
if not compact_items or compact_items[-1].get("type") not in {
"compaction",
"compaction_summary",
"context_compaction",
}:
raise RuntimeError("Codex compaction returned no compaction item")
body["input"] = [
*_retained_compaction_messages(input_items),
*compact_items,
]
except Exception as compact_error:
if is_compaction_compatibility_error(compact_error):
self._native_compaction_available = False
logger.warning(
"Codex native compaction unavailable; continuing without it "
"(type={} status={} disabled={})",
type(compact_error).__name__,
getattr(compact_error, "status_code", None),
not self._native_compaction_available,
)
stage = "codex_request"
return await _send(body, emit_deltas=True)
except Exception as e:
response = _codex_error_response(e)
exc_type = "CodexHTTPError" if isinstance(e, _CodexHTTPError) else type(e).__name__
@@ -137,8 +225,28 @@ class OpenAICodexProvider(LLMProvider):
model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7,
reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None,
provider_context: ProviderCallContext | None = None,
) -> LLMResponse:
return await self._call_codex(messages, tools, model, reasoning_effort, tool_choice)
return await self._call_codex(
messages,
tools,
model,
max_tokens,
reasoning_effort,
tool_choice,
provider_context=provider_context,
)
async def chat_with_context(
self,
*,
provider_context: ProviderCallContext,
**kwargs: Any,
) -> LLMResponse:
return await self.chat(
**kwargs,
provider_context=provider_context,
)
async def chat_stream(
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None,
@@ -148,21 +256,55 @@ class OpenAICodexProvider(LLMProvider):
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
provider_context: ProviderCallContext | None = None,
) -> LLMResponse:
return await self._call_codex(
messages,
tools,
model,
reasoning_effort,
tool_choice,
on_content_delta,
on_thinking_delta,
on_tool_call_delta,
messages=messages,
tools=tools,
model=model,
max_tokens=max_tokens,
reasoning_effort=reasoning_effort,
tool_choice=tool_choice,
on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta,
provider_context=provider_context,
)
async def chat_stream_with_context(
self,
*,
provider_context: ProviderCallContext,
**kwargs: Any,
) -> LLMResponse:
return await self.chat_stream(
**kwargs,
provider_context=provider_context,
)
def get_default_model(self) -> str:
return self.default_model
@staticmethod
def _responses_state_provider() -> str:
return f"openai_codex:{DEFAULT_CODEX_URL.rstrip('/')}"
def can_resume_conversation_state(
self,
state: ProviderConversationState,
model: str | None = None,
) -> bool:
return responses_state_matches(
state,
provider=self._responses_state_provider(),
model=_strip_model_prefix(model or self.default_model),
)
def supports_native_compaction(self, model: str | None = None) -> bool:
"""Use the Codex backend's inline compaction trigger when needed."""
_ = model
return self._native_compaction_available
def _strip_model_prefix(model: str) -> str:
if model.startswith("openai-codex/") or model.startswith("openai_codex/"):
@@ -170,6 +312,58 @@ def _strip_model_prefix(model: str) -> str:
return model
def _without_response_item_ids(
request_body: dict[str, Any],
) -> dict[str, Any]:
"""Match Codex's default ``store=false`` request-item contract."""
if request_body.get("store") is True:
return request_body
raw_input = request_body.get("input")
if not isinstance(raw_input, list):
return request_body
input_items: list[object] = cast(list[object], raw_input)
sanitized_input: list[object] = []
for raw_item in input_items:
if not isinstance(raw_item, dict):
sanitized_input.append(raw_item)
continue
item = cast(dict[str, Any], raw_item)
sanitized_input.append({
key: value
for key, value in item.items()
if key != "id"
})
body = dict(request_body)
body["input"] = sanitized_input
return body
def _retained_compaction_messages(
input_items: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Mirror Codex's bounded retention of user/developer/system messages."""
retained_reversed: list[dict[str, Any]] = []
remaining = _COMPACTION_RETAINED_CHAR_BUDGET
for item in reversed(input_items):
if item.get("type") not in {None, "message"} or item.get("role") not in {
"user",
"developer",
"system",
}:
continue
size = len(json.dumps(item, ensure_ascii=False))
if size > remaining and retained_reversed:
continue
retained_reversed.append(item)
remaining = max(0, remaining - size)
if remaining == 0:
break
retained_reversed.reverse()
return retained_reversed
def _build_reasoning_options(reasoning_effort: str | None) -> dict[str, str] | None:
"""Opt in to visible summaries without changing provider-default effort."""
if reasoning_effort and reasoning_effort.lower() == "none":
@@ -202,6 +396,7 @@ class _CodexHTTPError(RuntimeError):
error_type: str | None = None,
error_code: str | None = None,
should_retry: bool | None = None,
compaction_unsupported: bool = False,
):
super().__init__(message)
self.status_code = status_code
@@ -209,6 +404,7 @@ class _CodexHTTPError(RuntimeError):
self.error_type = error_type
self.error_code = error_code
self.should_retry = should_retry
self.compaction_unsupported = compaction_unsupported
async def _request_codex(
@@ -220,7 +416,7 @@ async def _request_codex(
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
) -> LLMResponse:
idle_timeout_s = resolve_stream_idle_timeout_s()
client_kwargs: dict[str, Any] = {"timeout": idle_timeout_s, "verify": verify}
if proxy:
@@ -233,6 +429,17 @@ async def _request_codex(
raw = text.decode("utf-8", "ignore")
retry_after = LLMProvider._extract_retry_after_from_headers(response.headers)
error_type, error_code = LLMProvider._extract_error_type_code(raw)
compaction_unsupported = (
response.status_code in {400, 404, 422}
and any(
marker in raw.lower()
for marker in (
"context_management",
"compact_threshold",
"compaction_trigger",
)
)
)
raise _CodexHTTPError(
_friendly_error(response.status_code, raw),
status_code=response.status_code,
@@ -240,13 +447,38 @@ async def _request_codex(
error_type=error_type,
error_code=error_code,
should_retry=_should_retry_status(response.status_code, error_type, error_code, raw),
compaction_unsupported=compaction_unsupported,
)
return await consume_sse_with_reasoning(
capture = ResponsesStreamCapture()
(
content,
tool_calls,
finish_reason,
usage,
reasoning_content,
) = await consume_sse_with_reasoning(
response,
on_content_delta=on_content_delta,
on_tool_call_delta=on_tool_call_delta,
on_reasoning_delta=on_thinking_delta,
capture=capture,
)
result = LLMResponse(
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
usage=usage,
reasoning_content=reasoning_content,
)
if capture.completed and is_replayable_finish_reason(finish_reason):
result.provider_state = build_responses_state(
provider=f"openai_codex:{url.rstrip('/')}",
model=str(body.get("model") or ""),
input_items=cast(list[dict[str, Any]], body.get("input") or []),
output_items=capture.output_items,
usage=usage,
)
return result
def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
+151 -11
View File
@@ -26,16 +26,24 @@ from pydantic.alias_generators import to_snake
from nanobot.providers.base import (
LLMProvider,
LLMResponse,
ProviderCallContext,
ProviderConversationState,
ToolCallRequest,
parse_tool_arguments,
resolve_stream_idle_timeout_s,
tool_arguments_json_for_replay,
)
from nanobot.providers.openai_responses import (
ResponsesStreamCapture,
build_responses_state,
consume_sdk_stream,
convert_messages,
convert_tools,
is_compaction_compatibility_error,
is_replayable_finish_reason,
parse_response_output,
prepare_responses_input,
resolve_compact_threshold,
responses_state_matches,
)
if TYPE_CHECKING:
@@ -443,6 +451,8 @@ class OpenAICompatProvider(LLMProvider):
registry lookups needed.
"""
_native_compaction_available = True
def __init__(
self,
api_key: str | None = None,
@@ -463,6 +473,7 @@ class OpenAICompatProvider(LLMProvider):
self._api_type = api_type if spec and spec.name == "openai" else "auto"
self._extra_query = extra_query or {}
self._proxy = proxy or None
self._native_compaction_available = True
if api_key and spec and spec.env_key:
self._setup_env(api_key, api_base)
@@ -971,6 +982,37 @@ class OpenAICompatProvider(LLMProvider):
return self._responses_circuit_allows_probe(model, reasoning_effort)
def _responses_state_provider(self) -> str:
spec_name = self._spec.name if self._spec is not None else "custom"
effective_base = self._effective_base or "https://api.openai.com/v1"
return f"openai_compat:{spec_name}:{effective_base.rstrip('/')}"
def _responses_state_model(self, model: str | None) -> str:
return self._request_model_name(model or self.default_model)
def can_resume_conversation_state(
self,
state: ProviderConversationState,
model: str | None = None,
) -> bool:
return responses_state_matches(
state,
provider=self._responses_state_provider(),
model=self._responses_state_model(model),
)
def supports_native_compaction(self, model: str | None = None) -> bool:
"""Enable server compaction only on direct OpenAI Responses endpoints."""
_ = model
if (
not self._native_compaction_available
or self._api_type == "chat_completions"
):
return False
if self._spec is not None and self._spec.name != "openai":
return False
return _is_direct_openai_base(self._effective_base)
def _responses_circuit_allows_probe(
self,
model: str | None,
@@ -1040,12 +1082,29 @@ class OpenAICompatProvider(LLMProvider):
temperature: float,
reasoning_effort: str | None,
tool_choice: str | dict[str, Any] | None,
provider_context: ProviderCallContext | None = None,
) -> dict[str, Any]:
"""Build a Responses API body for direct OpenAI requests."""
model_name = model or self.default_model
model_name = self._request_model_name(model_name)
sanitized_messages = self._sanitize_messages(self._sanitize_empty_content(messages))
instructions, input_items = convert_messages(sanitized_messages)
sanitized_state = (
provider_context.conversation_state
if provider_context is not None
else None
)
if sanitized_state is not None:
sanitized_state = sanitized_state.with_pending_messages(
self._sanitize_messages(
self._sanitize_empty_content(sanitized_state.pending_messages)
)
)
instructions, input_items, replayed = prepare_responses_input(
sanitized_messages,
state=sanitized_state,
provider=self._responses_state_provider(),
model=model_name,
)
body: dict[str, Any] = {
"model": model_name,
@@ -1055,13 +1114,29 @@ class OpenAICompatProvider(LLMProvider):
"store": False,
"stream": False,
}
compact_threshold = resolve_compact_threshold(
(
provider_context.context_window_tokens
if provider_context is not None
else None
),
max_tokens,
)
if self.supports_native_compaction(model_name) and compact_threshold is not None:
body["context_management"] = [{
"type": "compaction",
"compact_threshold": compact_threshold,
}]
if self._supports_temperature(model_name, reasoning_effort):
body["temperature"] = temperature
if not self._supports_temperature(model_name, reasoning_effort):
body["include"] = ["reasoning.encrypted_content"]
if reasoning_effort and reasoning_effort.lower() != "none":
body["reasoning"] = {"effort": reasoning_effort}
body["include"] = ["reasoning.encrypted_content"]
if replayed and "gpt-5.6" in model_name.lower():
body.setdefault("reasoning", {})["context"] = "all_turns"
if tools:
body["tools"] = convert_tools(tools)
@@ -1073,6 +1148,29 @@ class OpenAICompatProvider(LLMProvider):
return body
async def _create_response_with_compaction_fallback(
self,
client: Any,
body: dict[str, Any],
) -> Any:
"""Retry Responses once without server compaction on compatibility errors."""
try:
return await client.responses.create(**body)
except Exception as exc:
if (
"context_management" not in body
or not is_compaction_compatibility_error(exc)
):
raise
self._native_compaction_available = False
body.pop("context_management", None)
logger.warning(
"Responses server compaction unsupported; disabled for this provider instance "
"(status={})",
getattr(exc, "status_code", None),
)
return await client.responses.create(**body)
# ------------------------------------------------------------------
# Response parsing
# ------------------------------------------------------------------
@@ -1599,6 +1697,28 @@ class OpenAICompatProvider(LLMProvider):
# Public API
# ------------------------------------------------------------------
async def chat_with_context(
self,
*,
provider_context: ProviderCallContext,
**kwargs: Any,
) -> LLMResponse:
return await self.chat(
**kwargs,
provider_context=provider_context,
)
async def chat_stream_with_context(
self,
*,
provider_context: ProviderCallContext,
**kwargs: Any,
) -> LLMResponse:
return await self.chat_stream(
**kwargs,
provider_context=provider_context,
)
async def chat(
self,
messages: list[dict[str, Any]],
@@ -1608,6 +1728,7 @@ class OpenAICompatProvider(LLMProvider):
temperature: float = 0.7,
reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None,
provider_context: ProviderCallContext | None = None,
) -> LLMResponse:
client = await self._ensure_client()
try:
@@ -1616,12 +1737,18 @@ class OpenAICompatProvider(LLMProvider):
body = self._build_responses_body(
messages, tools, model, max_tokens, temperature,
reasoning_effort, tool_choice,
provider_context,
)
responses_raw = cast(
Any,
await client.responses.create(**body),
responses_raw = await self._create_response_with_compaction_fallback(
client,
body,
)
result = parse_response_output(
responses_raw,
state_provider=self._responses_state_provider(),
state_model=str(body["model"]),
state_input_items=cast(list[dict[str, Any]], body["input"]),
)
result = parse_response_output(responses_raw)
self._record_responses_success(model, reasoning_effort)
return result
except Exception as responses_error:
@@ -1660,6 +1787,7 @@ class OpenAICompatProvider(LLMProvider):
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
provider_context: ProviderCallContext | None = None,
) -> LLMResponse:
client = await self._ensure_client()
idle_timeout_s = resolve_stream_idle_timeout_s()
@@ -1669,11 +1797,12 @@ class OpenAICompatProvider(LLMProvider):
body = self._build_responses_body(
messages, tools, model, max_tokens, temperature,
reasoning_effort, tool_choice,
provider_context,
)
body["stream"] = True
responses_stream = cast(
Any,
await client.responses.create(**body),
responses_stream = await self._create_response_with_compaction_fallback(
client,
body,
)
async def _timed_stream() -> AsyncIterator[Any]:
@@ -1687,6 +1816,7 @@ class OpenAICompatProvider(LLMProvider):
except StopAsyncIteration:
break
capture = ResponsesStreamCapture()
(
content,
tool_calls,
@@ -1697,15 +1827,25 @@ class OpenAICompatProvider(LLMProvider):
_timed_stream(),
on_content_delta,
on_tool_call_delta=on_tool_call_delta,
capture=capture,
)
self._record_responses_success(model, reasoning_effort)
return LLMResponse(
result = LLMResponse(
content=content or None,
tool_calls=tool_calls,
finish_reason=finish_reason,
usage=usage,
reasoning_content=reasoning_content,
)
if capture.completed and is_replayable_finish_reason(finish_reason):
result.provider_state = build_responses_state(
provider=self._responses_state_provider(),
model=str(body["model"]),
input_items=cast(list[dict[str, Any]], body["input"]),
output_items=capture.output_items,
usage=usage,
)
return result
except Exception as responses_error:
if self._spec and self._spec.name == "github_copilot":
# Copilot gateway exposes GPT-5/o-series only via /responses;
+21 -1
View File
@@ -1,4 +1,4 @@
"""Shared helpers for OpenAI Responses API providers (Codex, Azure OpenAI)."""
"""Shared helpers for provider backends that implement the OpenAI Responses protocol."""
from nanobot.providers.openai_responses.converters import (
convert_messages,
@@ -8,13 +8,24 @@ from nanobot.providers.openai_responses.converters import (
)
from nanobot.providers.openai_responses.parsing import (
FINISH_REASON_MAP,
ResponsesStreamCapture,
consume_sdk_stream,
consume_sse,
consume_sse_with_reasoning,
is_replayable_finish_reason,
iter_sse,
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,
responses_state_matches,
)
__all__ = [
"convert_messages",
@@ -25,7 +36,16 @@ __all__ = [
"consume_sse",
"consume_sse_with_reasoning",
"consume_sdk_stream",
"ResponsesStreamCapture",
"is_replayable_finish_reason",
"map_finish_reason",
"parse_response_output",
"build_responses_state",
"is_compaction_compatibility_error",
"prepare_responses_input",
"resolve_compact_threshold",
"responses_state_context_tokens",
"responses_state_items",
"responses_state_matches",
"FINISH_REASON_MAP",
]
+246 -10
View File
@@ -4,12 +4,14 @@ from __future__ import annotations
import json
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any, AsyncGenerator, cast
import httpx
from loguru import logger
from nanobot.providers.base import LLMResponse, ToolCallRequest, parse_tool_arguments
from nanobot.providers.openai_responses.state import build_responses_state
FINISH_REASON_MAP = {
"completed": "stop",
@@ -17,6 +19,42 @@ FINISH_REASON_MAP = {
"failed": "error",
"cancelled": "error",
}
REPLAYABLE_FINISH_REASONS = frozenset({"stop", "tool_calls", "function_call"})
@dataclass(slots=True)
class ResponsesStreamCapture:
"""Losslessly capture terminal output items without changing stream results."""
completed: bool = False
response: dict[str, Any] | None = field(default=None, repr=False)
_items_by_index: dict[int, dict[str, Any]] = field(default_factory=dict, repr=False)
def record_output_item(self, index: object, item: object) -> None:
item_object = _response_object(item)
if item_object is None:
return
output_index = (
index
if isinstance(index, int) and not isinstance(index, bool)
else len(self._items_by_index)
)
self._items_by_index[output_index] = item_object
def record_completed(self, response: object) -> None:
response_object = _response_object(response)
if response_object is None:
return
self.completed = True
self.response = response_object
@property
def output_items(self) -> list[dict[str, Any]]:
if self.response is not None:
output = _response_object_list(self.response.get("output"))
if output:
return output
return [self._items_by_index[index] for index in sorted(self._items_by_index)]
def _as_json_object(value: object) -> dict[str, Any] | None:
@@ -54,6 +92,27 @@ def map_finish_reason(status: str | None) -> str:
return FINISH_REASON_MAP.get(status or "completed", "stop")
def is_replayable_finish_reason(finish_reason: str) -> bool:
"""Return whether a response can safely advance opaque conversation state."""
return finish_reason in REPLAYABLE_FINISH_REASONS
def _response_finish_reason(
response: object,
*,
fallback_status: str | None = None,
) -> str:
"""Map terminal response details without treating content filtering as truncation."""
response_object = _response_object(response) or {}
status = response_object.get("status")
terminal_status = status if isinstance(status, str) else fallback_status
if terminal_status == "incomplete":
details = _response_object(response_object.get("incomplete_details"))
if details is not None and details.get("reason") == "content_filter":
return "content_filter"
return map_finish_reason(terminal_status)
def _usage_from_response_obj(response: object) -> dict[str, int]:
response_object = _response_object(response)
usage_raw: object = (
@@ -99,6 +158,47 @@ def _tool_arguments_source(*values: Any) -> Any:
return "{}"
def _refusal_event_key(
item_id: object,
content_index: object,
) -> tuple[str | None, int | None]:
"""Identify one streamed refusal content part across delta/done events."""
return (
item_id if isinstance(item_id, str) else None,
(
content_index
if isinstance(content_index, int) and not isinstance(content_index, bool)
else None
),
)
def _remaining_refusal_text(streamed_text: str, refusal_text: str) -> str:
"""Return only text not already surfaced by refusal deltas."""
if not streamed_text:
return refusal_text
if refusal_text.startswith(streamed_text):
return refusal_text[len(streamed_text):]
return ""
def _extract_refusal_text_from_output(output: object) -> tuple[bool, str]:
"""Extract refusal content from terminal Responses output items."""
refusal_seen = False
parts: list[str] = []
for item in _response_object_list(output):
if item.get("type") != "message":
continue
for block in _response_object_list(item.get("content")):
if block.get("type") != "refusal":
continue
refusal_seen = True
refusal_text = block.get("refusal")
if isinstance(refusal_text, str):
parts.append(refusal_text)
return refusal_seen, "".join(parts)
async def iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], None]:
"""Yield parsed JSON events from a Responses API SSE stream."""
buffer: list[str] = []
@@ -153,6 +253,7 @@ async def consume_sse_with_reasoning(
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
on_reasoning_delta: Callable[[str], Awaitable[None]] | None = None,
on_response_event: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
capture: ResponsesStreamCapture | None = None,
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
"""Consume a Responses API SSE stream, including visible reasoning summaries."""
content = ""
@@ -163,6 +264,9 @@ async def consume_sse_with_reasoning(
usage: dict[str, int] = {}
reasoning_content: str | None = None
streamed_reasoning = False
refusal_seen = False
refusal_deltas: dict[tuple[str | None, int | None], str] = {}
emitted_refusal_text = ""
async for event in iter_sse(response):
if on_response_event:
@@ -191,6 +295,33 @@ async def consume_sse_with_reasoning(
content += delta_text
if on_content_delta and delta_text:
await on_content_delta(delta_text)
elif event_type == "response.refusal.delta":
refusal_seen = True
delta_text = event.get("delta")
if isinstance(delta_text, str) and delta_text:
key = _refusal_event_key(
event.get("item_id"),
event.get("content_index"),
)
refusal_deltas[key] = refusal_deltas.get(key, "") + delta_text
content += delta_text
emitted_refusal_text += delta_text
if on_content_delta:
await on_content_delta(delta_text)
elif event_type == "response.refusal.done":
refusal_seen = True
refusal_text = event.get("refusal")
key = _refusal_event_key(
event.get("item_id"),
event.get("content_index"),
)
streamed_text = refusal_deltas.pop(key, "")
if isinstance(refusal_text, str) and refusal_text:
remaining_text = _remaining_refusal_text(streamed_text, refusal_text)
content += remaining_text
emitted_refusal_text += remaining_text
if on_content_delta and remaining_text:
await on_content_delta(remaining_text)
elif event_type == "response.reasoning_summary_text.delta":
delta_text = event.get("delta") or ""
if delta_text:
@@ -239,6 +370,8 @@ async def consume_sse_with_reasoning(
})
elif event_type == "response.output_item.done":
item = _as_json_object(event.get("item")) or {}
if capture is not None:
capture.record_output_item(event.get("output_index"), item)
if item.get("type") == "function_call":
call_id = item.get("call_id")
if not call_id:
@@ -269,11 +402,28 @@ async def consume_sse_with_reasoning(
reasoning_content = summary
if on_reasoning_delta:
await on_reasoning_delta(summary)
elif event_type == "response.completed":
elif event_type in {"response.completed", "response.incomplete"}:
response_obj = _response_object(event.get("response")) or {}
status = response_obj.get("status")
finish_reason = map_finish_reason(status)
if capture is not None:
capture.record_completed(response_obj)
finish_reason = _response_finish_reason(
response_obj,
fallback_status=event_type.removeprefix("response."),
)
usage = _usage_from_response_obj(response_obj) or usage
terminal_refusal, terminal_refusal_text = _extract_refusal_text_from_output(
response_obj.get("output")
)
if terminal_refusal:
refusal_seen = True
remaining_text = _remaining_refusal_text(
emitted_refusal_text,
terminal_refusal_text,
)
content += remaining_text
emitted_refusal_text += remaining_text
if on_content_delta and remaining_text:
await on_content_delta(remaining_text)
if not reasoning_content:
summary = _extract_reasoning_summary_from_output(response_obj.get("output"))
if summary:
@@ -284,6 +434,8 @@ async def consume_sse_with_reasoning(
detail = event.get("error") or event.get("message") or event
raise RuntimeError(f"Response failed: {str(detail)[:500]}")
if refusal_seen:
finish_reason = "refusal"
return content, tool_calls, finish_reason, usage, reasoning_content
@@ -300,7 +452,13 @@ def _extract_reasoning_summary_from_output(output: object) -> str | None:
return "".join(parts) or None
def parse_response_output(response: object) -> LLMResponse:
def parse_response_output(
response: object,
*,
state_provider: str | None = None,
state_model: str | None = None,
state_input_items: list[dict[str, Any]] | None = None,
) -> LLMResponse:
"""Parse an SDK ``Response`` object into an ``LLMResponse``."""
response_object = _response_object(response) or {}
@@ -308,15 +466,22 @@ def parse_response_output(response: object) -> LLMResponse:
content_parts: list[str] = []
tool_calls: list[ToolCallRequest] = []
reasoning_content: str | None = None
refusal_seen = False
for item in output:
item_type = item.get("type")
if item_type == "message":
for block in _response_object_list(item.get("content")):
if block.get("type") == "output_text":
block_type = block.get("type")
if block_type == "output_text":
text = block.get("text")
if isinstance(text, str):
content_parts.append(text)
elif block_type == "refusal":
refusal_seen = True
refusal = block.get("refusal")
if isinstance(refusal, str):
content_parts.append(refusal)
elif item_type == "reasoning":
for s in _response_object_list(item.get("summary")):
if s.get("type") == "summary_text" and s.get("text"):
@@ -337,21 +502,37 @@ def parse_response_output(response: object) -> LLMResponse:
usage = _usage_from_response_obj(response_object)
status = response_object.get("status")
finish_reason = map_finish_reason(status if isinstance(status, str) else None)
finish_reason = "refusal" if refusal_seen else _response_finish_reason(response_object)
return LLMResponse(
result = LLMResponse(
content="".join(content_parts) or None,
tool_calls=tool_calls,
finish_reason=finish_reason,
usage=usage,
reasoning_content=reasoning_content if isinstance(reasoning_content, str) else None,
)
if (
state_provider is not None
and state_model is not None
and state_input_items is not None
and (status is None or status == "completed")
and is_replayable_finish_reason(finish_reason)
):
result.provider_state = build_responses_state(
provider=state_provider,
model=state_model,
input_items=state_input_items,
output_items=output,
usage=usage,
)
return result
async def consume_sdk_stream(
stream: Any,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
capture: ResponsesStreamCapture | None = None,
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
"""Consume an SDK async stream from ``client.responses.create(stream=True)``."""
content = ""
@@ -361,6 +542,9 @@ async def consume_sdk_stream(
finish_reason = "stop"
usage: dict[str, int] = {}
reasoning_content: str | None = None
refusal_seen = False
refusal_deltas: dict[tuple[str | None, int | None], str] = {}
emitted_refusal_text = ""
async for raw_event in stream:
event: Any = raw_event
@@ -388,6 +572,33 @@ async def consume_sdk_stream(
content += delta_text
if on_content_delta and delta_text:
await on_content_delta(delta_text)
elif event_type == "response.refusal.delta":
refusal_seen = True
delta_text = getattr(event, "delta", None)
if isinstance(delta_text, str) and delta_text:
key = _refusal_event_key(
getattr(event, "item_id", None),
getattr(event, "content_index", None),
)
refusal_deltas[key] = refusal_deltas.get(key, "") + delta_text
content += delta_text
emitted_refusal_text += delta_text
if on_content_delta:
await on_content_delta(delta_text)
elif event_type == "response.refusal.done":
refusal_seen = True
refusal_text = getattr(event, "refusal", None)
key = _refusal_event_key(
getattr(event, "item_id", None),
getattr(event, "content_index", None),
)
streamed_text = refusal_deltas.pop(key, "")
if isinstance(refusal_text, str) and refusal_text:
remaining_text = _remaining_refusal_text(streamed_text, refusal_text)
content += remaining_text
emitted_refusal_text += remaining_text
if on_content_delta and remaining_text:
await on_content_delta(remaining_text)
elif event_type == "response.function_call_arguments.delta":
call_id = getattr(event, "call_id", None)
if call_id and call_id in tool_call_buffers:
@@ -416,6 +627,8 @@ async def consume_sdk_stream(
})
elif event_type == "response.output_item.done":
item = getattr(event, "item", None)
if capture is not None:
capture.record_output_item(getattr(event, "output_index", None), item)
if item and getattr(item, "type", None) == "function_call":
call_id = getattr(item, "call_id", None)
if not call_id:
@@ -443,10 +656,31 @@ async def consume_sdk_stream(
arguments=args,
)
)
elif event_type == "response.completed":
elif event_type in {"response.completed", "response.incomplete"}:
resp = getattr(event, "response", None)
status = getattr(resp, "status", None) if resp else None
finish_reason = map_finish_reason(status)
response_obj = _response_object(resp) or {}
if capture is not None:
capture.record_completed(resp)
finish_reason = _response_finish_reason(
resp,
fallback_status=event_type.removeprefix("response."),
)
terminal_output = response_obj.get("output")
if terminal_output is None:
terminal_output = getattr(resp, "output", None)
terminal_refusal, terminal_refusal_text = _extract_refusal_text_from_output(
terminal_output
)
if terminal_refusal:
refusal_seen = True
remaining_text = _remaining_refusal_text(
emitted_refusal_text,
terminal_refusal_text,
)
content += remaining_text
emitted_refusal_text += remaining_text
if on_content_delta and remaining_text:
await on_content_delta(remaining_text)
if resp:
usage_obj = getattr(resp, "usage", None)
if usage_obj:
@@ -466,4 +700,6 @@ async def consume_sdk_stream(
detail = getattr(event, "error", None) or getattr(event, "message", None) or event
raise RuntimeError(f"Response failed: {str(detail)[:500]}")
if refusal_seen:
finish_reason = "refusal"
return content, tool_calls, finish_reason, usage, reasoning_content
+197
View File
@@ -0,0 +1,197 @@
"""Opaque conversation state for Responses API item replay."""
from __future__ import annotations
from copy import deepcopy
from typing import Any, cast
from loguru import logger
from nanobot.providers.base import ProviderConversationState
from nanobot.providers.openai_responses.converters import convert_messages
RESPONSES_STATE_KIND = "openai_responses"
RESPONSES_STATE_VERSION = 1
_ITEMS_KEY = "items"
_CONTEXT_TOKENS_KEY = "context_tokens"
_COMPACTION_ITEM_TYPES = frozenset({
"compaction",
"compaction_summary",
"context_compaction",
})
def responses_state_matches(
state: ProviderConversationState,
*,
provider: str,
model: str,
) -> bool:
"""Return whether *state* belongs to this exact Responses endpoint/model."""
return (
state.kind == RESPONSES_STATE_KIND
and state.version == RESPONSES_STATE_VERSION
and state.provider == provider
and state.model == model
and _state_items(state) is not None
)
def prepare_responses_input(
messages: list[dict[str, Any]],
*,
state: ProviderConversationState | None,
provider: str,
model: str,
) -> tuple[str, list[dict[str, Any]], bool]:
"""Build a request from exact prior items plus only newly appended messages.
The full Chat transcript remains the source for the current instructions.
When no compatible state exists, it is converted normally as a safe
fallback.
"""
instructions, fallback_items = convert_messages(messages)
if state is None or not responses_state_matches(
state,
provider=provider,
model=model,
):
return instructions, fallback_items, False
prior_items = _state_items(state)
if prior_items is None:
return instructions, fallback_items, False
_, delta_items = convert_messages(state.pending_messages)
logger.debug(
"Replaying Responses state: prior_items={} pending_messages={}",
len(prior_items),
len(state.pending_messages),
)
return instructions, [*deepcopy(prior_items), *delta_items], True
def build_responses_state(
*,
provider: str,
model: str,
input_items: list[dict[str, Any]],
output_items: list[dict[str, Any]],
usage: dict[str, int] | None = None,
) -> ProviderConversationState:
"""Create the canonical next state from request input and every output item."""
unpruned_items = [*input_items, *output_items]
items = _prune_before_latest_output_compaction(input_items, output_items)
if len(items) < len(unpruned_items):
logger.info(
"Installed Responses compaction: dropped_items={} retained_items={}",
len(unpruned_items) - len(items),
len(items),
)
payload: dict[str, Any] = {_ITEMS_KEY: deepcopy(items)}
context_tokens = _context_tokens_from_usage(usage)
if context_tokens > 0:
payload[_CONTEXT_TOKENS_KEY] = context_tokens
return ProviderConversationState(
kind=RESPONSES_STATE_KIND,
provider=provider,
model=model,
version=RESPONSES_STATE_VERSION,
payload=payload,
)
def responses_state_items(
state: ProviderConversationState,
) -> list[dict[str, Any]] | None:
"""Return an isolated copy of canonical input items for tests/consumers."""
items = _state_items(state)
return deepcopy(items) if items is not None else None
def responses_state_context_tokens(state: ProviderConversationState) -> int:
"""Return the last server-reported active context size."""
value = state.payload.get(_CONTEXT_TOKENS_KEY)
if isinstance(value, bool) or not isinstance(value, int):
return 0
return max(0, value)
def resolve_compact_threshold(
context_window_tokens: int | None,
max_output_tokens: int,
) -> int | None:
"""Derive Codex-compatible 90% compaction headroom for a model window."""
if context_window_tokens is None or context_window_tokens <= 0:
return None
ninety_percent = max(1, context_window_tokens * 9 // 10)
output_headroom = max(1, context_window_tokens - max(1, max_output_tokens))
return min(ninety_percent, output_headroom)
def is_compaction_compatibility_error(exc: Exception) -> bool:
"""Recognize endpoints that reject native Responses compaction fields."""
if getattr(exc, "compaction_unsupported", False) is True:
return True
response = getattr(exc, "response", None)
status_code = getattr(exc, "status_code", None)
if status_code is None and response is not None:
status_code = getattr(response, "status_code", None)
body = (
getattr(exc, "body", None)
or getattr(exc, "doc", None)
or getattr(response, "text", None)
or str(exc)
)
text = str(body).lower()
has_compaction_marker = any(
marker in text
for marker in ("context_management", "compact_threshold", "compaction_trigger")
)
if not has_compaction_marker:
return False
return isinstance(exc, TypeError) or status_code in {400, 404, 422}
def _prune_before_latest_output_compaction(
input_items: list[dict[str, Any]],
output_items: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Drop old input only when this response emits a new compaction item.
A canonical compacted input may intentionally retain messages before its
compaction item. Those messages must survive ordinary subsequent responses.
"""
latest = None
for index, item in enumerate(output_items):
if item.get("type") in _COMPACTION_ITEM_TYPES:
latest = index
if latest is None:
return [*input_items, *output_items]
return output_items[latest:]
def _context_tokens_from_usage(usage: dict[str, int] | None) -> int:
if not usage:
return 0
prompt_tokens = usage.get("prompt_tokens", 0)
completion_tokens = usage.get("completion_tokens", 0)
total_tokens = usage.get("total_tokens", 0)
values = (prompt_tokens, completion_tokens, total_tokens)
if any(isinstance(value, bool) for value in values):
return 0
return max(0, total_tokens or prompt_tokens + completion_tokens)
def _state_items(
state: ProviderConversationState,
) -> list[dict[str, Any]] | None:
raw_items = state.payload.get(_ITEMS_KEY)
if not isinstance(raw_items, list):
return None
items: list[dict[str, Any]] = []
for raw in cast(list[object], raw_items):
if not isinstance(raw, dict):
return None
items.append(cast(dict[str, Any], raw))
return items
+1 -58
View File
@@ -12,7 +12,6 @@ from urllib.parse import urlparse
from urllib.request import getproxies, proxy_bypass
import httpx
import httpx2
_BLOCKED_NETWORKS = [
ipaddress.ip_network("0.0.0.0/8"),
@@ -30,7 +29,6 @@ _BLOCKED_NETWORKS = [
_URL_RE = re.compile(r"https?://[^\s\"'`;|<>]+", re.IGNORECASE)
_allowed_networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
_DNS_PIN_RESOLVER_LOCK = asyncio.Lock()
def is_loopback_host(host: str) -> bool:
@@ -197,30 +195,6 @@ def httpx_env_proxy_mounts() -> dict[str, httpx.AsyncBaseTransport | None]:
return mounts
def httpx2_env_proxy_mounts() -> dict[str, httpx2.AsyncBaseTransport | None]:
"""Build HTTPX2 proxy mounts while leaving direct routes to the base transport."""
proxies = getproxies()
mounts: dict[str, httpx2.AsyncBaseTransport | None] = {}
for scheme in ("http", "https", "all"):
proxy_url = proxies.get(scheme)
if proxy_url:
if "://" not in proxy_url:
proxy_url = f"http://{proxy_url}"
mounts[f"{scheme}://"] = httpx2.AsyncHTTPTransport(proxy=httpx2.Proxy(proxy_url))
if not mounts:
return {}
no_proxy = proxies.get("no", "")
if no_proxy == "*":
return {}
for entry in no_proxy.split(","):
pattern = _no_proxy_mount_pattern(entry.strip())
if pattern:
mounts[pattern] = None
return mounts
def _no_proxy_mount_pattern(hostname: str) -> str | None:
if not hostname:
return None
@@ -290,7 +264,7 @@ class UnsafeURLRequestError(httpx.RequestError):
class PinnedDNSAsyncTransport(httpx.AsyncBaseTransport):
"""HTTPX transport that pins each request to the IPs validated for its URL."""
_resolver_lock = _DNS_PIN_RESOLVER_LOCK
_resolver_lock = asyncio.Lock()
def __init__(
self,
@@ -314,37 +288,6 @@ class PinnedDNSAsyncTransport(httpx.AsyncBaseTransport):
await self._inner.aclose()
class Httpx2UnsafeURLRequestError(httpx2.RequestError):
"""Raised when an HTTPX2 request is rejected by URL safety validation."""
class Httpx2PinnedDNSAsyncTransport(httpx2.AsyncBaseTransport):
"""HTTPX2 transport that pins each request to the IPs validated for its URL."""
_resolver_lock = _DNS_PIN_RESOLVER_LOCK
def __init__(
self,
*,
allow_loopback: bool = False,
inner: httpx2.AsyncBaseTransport | None = None,
) -> None:
self._allow_loopback = allow_loopback
self._inner = inner or httpx2.AsyncHTTPTransport()
async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
url = str(request.url)
ok, error, resolved_ips = resolve_url_target(url, allow_loopback=self._allow_loopback)
if not ok:
raise Httpx2UnsafeURLRequestError(error, request=request)
async with self._resolver_lock:
with pin_resolved_url_dns(url, resolved_ips):
return await self._inner.handle_async_request(request)
async def aclose(self) -> None:
await self._inner.aclose()
def validate_resolved_url(url: str) -> tuple[bool, str]:
"""Validate an already-fetched URL (e.g. after redirect). Only checks the IP, skips DNS."""
try:
+53 -5
View File
@@ -17,6 +17,7 @@ from weakref import WeakValueDictionary
from loguru import logger
from nanobot.config.paths import get_legacy_sessions_dir
from nanobot.providers.base import ProviderConversationState
from nanobot.runtime_context import (
RUNTIME_CONTEXT_HISTORY_META,
public_history_message,
@@ -43,6 +44,10 @@ _SESSION_PREVIEW_MAX_CHARS = 120
_SESSION_LIST_PREVIEW_MAX_RECORDS = 200
_SESSION_LIST_PREVIEW_MAX_CHARS = 1_000_000
_SESSION_DATA_ERRORS = (ValueError, TypeError, AttributeError, KeyError)
_PROVIDER_STATE_RECORD_TYPE = "provider_state"
_PROVIDER_STATE_RECORD_PREFIX_RE = re.compile(
r'^\s*\{\s*"_type"\s*:\s*"provider_state"\s*(?:,|\})'
)
_FORK_VOLATILE_METADATA_KEYS = {
"goal_state",
"pending_user_turn",
@@ -60,6 +65,11 @@ def _json_object(value: object) -> dict[str, Any]:
return cast(dict[str, Any], value)
def _is_provider_state_record_line(line: str) -> bool:
"""Recognize the canonical private record without decoding its opaque payload."""
return _PROVIDER_STATE_RECORD_PREFIX_RE.match(line) is not None
def replay_max_messages_for_context(context_window_tokens: int | None) -> int:
if not context_window_tokens or context_window_tokens <= 0:
return FILE_MAX_MESSAGES
@@ -146,10 +156,13 @@ class Session:
updated_at: datetime = field(default_factory=datetime.now)
metadata: dict[str, Any] = field(default_factory=dict)
last_consolidated: int = 0 # Number of messages already consolidated to files
provider_state: ProviderConversationState | None = field(default=None, repr=False)
def __post_init__(self) -> None:
if not isinstance(cast(object, self.metadata), dict):
self.metadata = {}
if not isinstance(cast(object, self.provider_state), ProviderConversationState):
self.provider_state = None
# An out-of-range offset (corrupt metadata) would hide all history; reset it.
last_consolidated = cast(object, self.last_consolidated)
if (
@@ -304,6 +317,7 @@ class Session:
"""Clear all messages and reset session to initial state."""
self.messages = []
self.last_consolidated = 0
self.provider_state = None
self.updated_at = datetime.now()
self.metadata.pop("_last_summary", None)
@@ -396,6 +410,8 @@ class Session:
self.messages = retained
self.last_consolidated = new_lc
if dropped:
self.provider_state = None
self.updated_at = datetime.now()
return RetentionResult(
dropped=dropped,
@@ -517,6 +533,7 @@ class JsonlSessionStore:
created_at: datetime | None = None
updated_at: datetime | None = None
last_consolidated = 0
provider_state: ProviderConversationState | None = None
with open(path, encoding="utf-8") as f:
for line in f:
@@ -527,7 +544,8 @@ class JsonlSessionStore:
raw_data: object = json.loads(line)
data = _json_object(raw_data)
if data.get("_type") == "metadata":
record_type = data.get("_type")
if record_type == "metadata":
metadata_value = cast(object, data.get("metadata", {}))
metadata = (
cast(dict[str, Any], metadata_value)
@@ -552,6 +570,10 @@ class JsonlSessionStore:
if isinstance(offset, int) and not isinstance(offset, bool)
else 0
)
elif record_type == _PROVIDER_STATE_RECORD_TYPE:
provider_state = ProviderConversationState.from_private_record(
data.get("state")
)
else:
messages.append(data)
@@ -562,6 +584,7 @@ class JsonlSessionStore:
updated_at=updated_at or datetime.now(),
metadata=metadata,
last_consolidated=last_consolidated,
provider_state=provider_state,
)
except _SESSION_DATA_ERRORS as e:
logger.warning("Failed to load session {}: {}", key, e)
@@ -586,6 +609,7 @@ class JsonlSessionStore:
created_at: datetime | None = None
updated_at: datetime | None = None
last_consolidated = 0
provider_state: ProviderConversationState | None = None
skipped = 0
with open(path, encoding="utf-8") as f:
@@ -603,7 +627,8 @@ class JsonlSessionStore:
continue
data = cast(dict[str, Any], raw_data)
if data.get("_type") == "metadata":
record_type = data.get("_type")
if record_type == "metadata":
metadata_value = cast(object, data.get("metadata", {}))
metadata = (
cast(dict[str, Any], metadata_value)
@@ -624,13 +649,21 @@ class JsonlSessionStore:
if isinstance(offset, int) and not isinstance(offset, bool)
else 0
)
elif record_type == _PROVIDER_STATE_RECORD_TYPE:
candidate = ProviderConversationState.from_private_record(
data.get("state")
)
if candidate is None:
skipped += 1
else:
provider_state = candidate
else:
messages.append(data)
if skipped:
logger.warning("Skipped {} corrupt lines in session {}", skipped, key)
if not messages and not metadata:
if not messages and not metadata and provider_state is None:
return None
return Session(
@@ -640,6 +673,7 @@ class JsonlSessionStore:
updated_at=updated_at or datetime.now(),
metadata=metadata,
last_consolidated=last_consolidated,
provider_state=provider_state,
)
except _SESSION_DATA_ERRORS as e:
logger.warning("Repair failed for session {}: {}", key, e)
@@ -670,6 +704,12 @@ class JsonlSessionStore:
"last_consolidated": session.last_consolidated,
}
f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n")
if session.provider_state is not None:
provider_state_line = {
"_type": _PROVIDER_STATE_RECORD_TYPE,
"state": session.provider_state.to_private_record(),
}
f.write(json.dumps(provider_state_line, ensure_ascii=False) + "\n")
for msg in session.messages:
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
if fsync:
@@ -726,7 +766,8 @@ class JsonlSessionStore:
continue
raw_data: object = json.loads(line)
data = _json_object(raw_data)
if data.get("_type") == "metadata":
record_type = data.get("_type")
if record_type == "metadata":
metadata_value = cast(object, data.get("metadata", {}))
metadata = (
cast(dict[str, Any], metadata_value)
@@ -745,6 +786,8 @@ class JsonlSessionStore:
stored_key = (
stored_key_value if isinstance(stored_key_value, str) else None
)
elif record_type == _PROVIDER_STATE_RECORD_TYPE:
continue
else:
messages.append(data)
return {
@@ -837,6 +880,8 @@ class JsonlSessionStore:
for line in f:
if not line.strip():
continue
if _is_provider_state_record_line(line):
continue
scanned_records += 1
scanned_chars += len(line)
if (
@@ -846,7 +891,10 @@ class JsonlSessionStore:
break
raw_item: object = json.loads(line)
item = _json_object(raw_item)
if item.get("_type") == "metadata":
if item.get("_type") in {
"metadata",
_PROVIDER_STATE_RECORD_TYPE,
}:
continue
text = _message_preview_text(item)
if not text:
+7 -4
View File
@@ -176,7 +176,10 @@ class GitStore:
)
if cast(object, sha_bytes) is None:
return None
sha = sha_bytes.hex()[:8]
# porcelain.commit returns the id as a 40-char hex string that is
# already encoded to bytes; .hex() would encode those ASCII bytes
# again and produce an id no git command can resolve.
sha = sha_bytes.decode()[:8]
logger.debug("Git auto-commit: {} ({})", sha, message)
return sha
except Exception as exc:
@@ -200,7 +203,7 @@ class GitStore:
return None
while sha:
if sha.hex().startswith(short_sha):
if sha.decode().startswith(short_sha):
return sha
commit_obj = repo[sha]
if commit_obj.type_name != b"commit":
@@ -280,7 +283,7 @@ class GitStore:
msg = commit.message.decode("utf-8", errors="replace").strip()
if message_prefix is None or msg.startswith(message_prefix):
entries.append(CommitInfo(
sha=sha.hex()[:8],
sha=sha.decode()[:8],
message=msg,
timestamp=ts,
))
@@ -484,7 +487,7 @@ class GitStore:
with Repo(str(self._workspace)) as repo:
commit = cast("Commit", repo[full_sha])
parent = commit.parents[0] if commit.parents else None
diff = self.diff_commits(parent.hex()[:8], c.sha) if parent else ""
diff = self.diff_commits(parent.decode()[:8], c.sha) if parent else ""
return c, diff
return None
except Exception as exc:
+6
View File
@@ -18,10 +18,12 @@ from loguru import logger
from nanobot.config.paths import get_webui_dir
from nanobot.session.history_visibility import is_hidden_history_message
from nanobot.session.manager import (
_PROVIDER_STATE_RECORD_TYPE, # pyright: ignore[reportPrivateUsage]
_SESSION_LIST_PREVIEW_MAX_CHARS, # pyright: ignore[reportPrivateUsage]
_SESSION_LIST_PREVIEW_MAX_RECORDS, # pyright: ignore[reportPrivateUsage]
Session,
SessionManager,
_is_provider_state_record_line, # pyright: ignore[reportPrivateUsage]
_message_preview_text, # pyright: ignore[reportPrivateUsage]
_metadata_title, # pyright: ignore[reportPrivateUsage]
)
@@ -298,7 +300,11 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
for line in f:
if not line.strip():
continue
if _is_provider_state_record_line(line):
continue
item = json.loads(line)
if item.get("_type") == _PROVIDER_STATE_RECORD_TYPE:
continue
timestamp = _visible_message_timestamp(item)
if timestamp is not None:
visible_message_at = _latest_updated_at(visible_message_at, timestamp)
+7
View File
@@ -159,6 +159,13 @@ def normalize_token_usage_state(raw: Any) -> dict[str, Any]:
if not isinstance(date, str) or len(date) != 10 or not isinstance(row_value, dict):
continue
row = cast(dict[str, Any], row_value)
try:
datetime.fromisoformat(date)
except ValueError:
# A hand-edited or foreign day key that is not a real date would
# otherwise reach token_usage_payload's date parsing and fail every
# settings request; drop it like any other malformed row.
continue
normalized = _normalize_usage_row(row)
if normalized["total_tokens"] <= 0 and normalized["requests"] <= 0:
continue
+20 -4
View File
@@ -2026,6 +2026,7 @@ def replay_transcript_to_ui_messages(
continue
close_activity_for_answer()
turn_fields = _turn_fields(rec, "answer")
source_fields = _source_fields(rec)
adopted = find_active_placeholder(messages, turn_fields) if buffer_message_id is None else None
if buffer_message_id is None:
if adopted:
@@ -2038,7 +2039,8 @@ def replay_transcript_to_ui_messages(
"role": "assistant",
"content": "",
"isStreaming": True,
**_turn_fields(rec, "answer"),
**turn_fields,
**source_fields,
"createdAt": _created_at_ms(rec, idx),
},
)
@@ -2050,7 +2052,8 @@ def replay_transcript_to_ui_messages(
**m,
"content": combined,
"isStreaming": True,
**_turn_fields(rec, "answer"),
**turn_fields,
**source_fields,
}
break
continue
@@ -2062,6 +2065,8 @@ def replay_transcript_to_ui_messages(
continue
merge_next = rec.get("resuming") is True and rec.get("merge_next") is True
final_text = rec.get("text")
turn_fields = _turn_fields(rec, "answer")
source_fields = _source_fields(rec)
if isinstance(final_text, str):
if buffer_message_id is None:
buffer_message_id = _new_id("buf", idx)
@@ -2071,7 +2076,8 @@ def replay_transcript_to_ui_messages(
"role": "assistant",
"content": final_text,
"isStreaming": True,
**_turn_fields(rec, "answer"),
**turn_fields,
**source_fields,
"createdAt": _created_at_ms(rec, idx),
},
)
@@ -2082,11 +2088,21 @@ def replay_transcript_to_ui_messages(
**m,
"content": final_text,
"isStreaming": True,
**_turn_fields(rec, "answer"),
**turn_fields,
**source_fields,
}
break
if merge_next:
buffer_parts = [final_text]
elif source_fields and buffer_message_id is not None:
for i, m in enumerate(messages):
if m.get("id") == buffer_message_id:
messages[i] = {
**m,
**turn_fields,
**source_fields,
}
break
if not merge_next:
buffer_message_id = None
buffer_parts = []
+1 -3
View File
@@ -31,8 +31,6 @@ dependencies = [
"websockets>=15.0,<17.0",
"websocket-client>=1.9.0,<2.0.0",
"httpx>=0.28.0,<1.0.0",
# MCP v2 uses the independently versioned httpx2 package for HTTP transports.
"httpx2>=2.5.0,<3.0.0",
"ddgs>=9.5.5,<10.0.0",
"oauth-cli-kit>=0.1.6,<1.0.0",
"loguru>=0.7.3,<1.0.0",
@@ -42,7 +40,7 @@ dependencies = [
"croniter>=6.0.0,<7.0.0",
"prompt-toolkit>=3.0.50,<4.0.0",
"questionary>=2.0.0,<3.0.0",
"mcp>=2.0.0,<3.0.0",
"mcp>=1.26.0,<2.0.0",
"json-repair>=0.57.0,<1.0.0",
"chardet>=3.0.2,<6.0.0",
"openai>=2.8.0",
+50
View File
@@ -154,6 +154,26 @@ class TestIsExpired:
now_over = datetime(2026, 1, 1, 10, 10, 0)
assert ac._is_expired(ts, now=now_over) is True
def test_unparseable_string_timestamp_returns_false(self):
"""A persisted timestamp that no longer parses must not raise.
list_sessions() forwards the raw persisted updated_at string, and
SessionManager._load already tolerates a malformed value through its
recovery path. The idle scan must mirror that tolerance instead of crashing.
"""
ac = _make_autocompact(ttl=15)
assert ac._is_expired("not-a-timestamp") is False
def test_tz_aware_string_timestamp_is_compared_by_instant(self):
"""A valid timestamp with an offset remains eligible for expiry."""
ac = _make_autocompact(ttl=15)
now = datetime(2026, 1, 1, 12, 0, 0)
recent = (now - timedelta(minutes=10)).astimezone().isoformat()
expired = (now - timedelta(minutes=20)).astimezone().isoformat()
assert ac._is_expired(recent, now=now) is False
assert ac._is_expired(expired, now=now) is True
# ---------------------------------------------------------------------------
# _format_summary
@@ -221,6 +241,36 @@ class TestCheckExpired:
assert len(scheduled) == 1
assert "cli:old" in ac._archiving
def test_unparseable_updated_at_does_not_stop_scan(self):
"""A malformed timestamp is skipped without hiding later sessions.
The idle scan runs from the agent loop's inbound-timeout branch, so a
raised exception here would tear down the loop. list_sessions() forwards
the raw string, so check_expired must tolerate it like SessionManager
does when loading.
"""
ac = _make_autocompact(ttl=15)
mock_sm = MagicMock(spec=SessionManager)
old_dt = datetime.now() - timedelta(minutes=20)
session = _make_session("cli:old", updated_at=old_dt)
_add_turns(session, 5)
mock_sm.list_sessions.return_value = [
{"key": "cli:corrupt", "updated_at": "not-a-timestamp"},
{"key": "cli:old", "updated_at": old_dt.isoformat()},
]
mock_sm.get_or_create.return_value = session
ac.sessions = mock_sm
scheduled = []
def scheduler(coro):
scheduled.append(coro)
coro.close()
ac.check_expired(scheduler, _runtime)
assert len(scheduled) == 1
assert ac._archiving == {"cli:old"}
@pytest.mark.asyncio
async def test_runtime_is_captured_before_background_starts(self):
ac = _make_autocompact(ttl=15)
+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
+13 -10
View File
@@ -10,9 +10,10 @@ from unittest.mock import MagicMock
import anyio
import pytest
from mcp import MCPError
from mcp import types as mcp_types
from mcp.shared.exceptions import McpError
from mcp.shared.message import SessionMessage
from mcp.types import ErrorData
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools import mcp as mcp_runtime
@@ -25,10 +26,12 @@ from nanobot.config.schema import MCPServerConfig
def _mcp_notification(method: str, params: dict[str, Any] | None = None) -> SessionMessage:
return SessionMessage(
message=mcp_types.JSONRPCNotification(
jsonrpc="2.0",
method=method,
params=params,
message=mcp_types.JSONRPCMessage(
mcp_types.JSONRPCNotification(
jsonrpc="2.0",
method=method,
params=params,
)
)
)
@@ -425,7 +428,7 @@ async def test_mcp_tool_reconnects_after_session_terminated(
self.call_count += 1
assert arguments == {"symbol": "AAPL"}
if self.index == 1:
raise MCPError(-32000, "Session terminated")
raise McpError(ErrorData(code=-32000, message="Session terminated"))
return SimpleNamespace(
content=[mcp_types.TextContent(type="text", text="recovered")]
)
@@ -440,7 +443,7 @@ async def test_mcp_tool_reconnects_after_session_terminated(
tool_def = SimpleNamespace(
name="quote",
description="quote tool",
input_schema={"type": "object", "properties": {}},
inputSchema={"type": "object", "properties": {}},
)
registry.register(MCPToolWrapper(session, name, tool_def, tool_timeout=5))
stack = AsyncExitStack()
@@ -481,7 +484,7 @@ async def test_mcp_reconnect_handler_uses_sanitized_server_prefix(
async def call_tool(self, _name: str, arguments: dict[str, Any]) -> Any:
assert arguments == {}
if self.index == 1:
raise MCPError(-32000, "Session terminated")
raise McpError(ErrorData(code=-32000, message="Session terminated"))
return SimpleNamespace(
content=[mcp_types.TextContent(type="text", text="recovered")]
)
@@ -494,7 +497,7 @@ async def test_mcp_reconnect_handler_uses_sanitized_server_prefix(
tool_def = SimpleNamespace(
name="quote",
description="quote tool",
input_schema={"type": "object", "properties": {}},
inputSchema={"type": "object", "properties": {}},
)
registry.register(MCPToolWrapper(_FakeSession(connect_count), name, tool_def))
stack = AsyncExitStack()
@@ -529,7 +532,7 @@ async def test_concurrent_mcp_reconnect_reuses_fresh_session(
class _DeadSession:
async def read_resource(self, _uri: str) -> Any:
raise MCPError(-32000, "Session terminated")
raise McpError(ErrorData(code=-32000, message="Session terminated"))
class _LiveSession:
async def read_resource(self, uri: str) -> Any:
+19 -19
View File
@@ -17,7 +17,7 @@ import socket
import time
from unittest.mock import MagicMock
import httpx2 as httpx
import httpx
import pytest
from nanobot.agent.loop import AgentLoop
@@ -27,8 +27,10 @@ from nanobot.bus.queue import MessageBus
from nanobot.config.schema import MCPServerConfig
from nanobot.security import network as security_network
_IDLE_TIMEOUT_SECONDS = 0.25
_IDLE_EXPIRY_GRACE_SECONDS = 0.25
# Leave enough headroom for reconnect handshakes on slower CI hosts; each test
# still waits beyond this deadline explicitly before exercising recovery.
_IDLE_TIMEOUT_SECONDS = 1.0
_IDLE_EXPIRY_GRACE_SECONDS = 0.5
_TOOL_TIMEOUT_SECONDS = 10
@@ -39,29 +41,31 @@ def _free_port() -> int:
def _run_mcp_server(port: int, ready_event: multiprocessing.Event) -> None:
"""MCPServer target for ``multiprocessing.Process``.
"""FastMCP server target for ``multiprocessing.Process``.
The server exposes a single ``greet`` tool and terminates idle sessions
after ``_IDLE_TIMEOUT_SECONDS``.
"""
import uvicorn
from mcp.server import MCPServer
from mcp.server.fastmcp import FastMCP
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
mcp = MCPServer("IdleTimeoutDemo")
mcp = FastMCP("IdleTimeoutDemo", json_response=True, port=port)
@mcp.tool()
def greet(name: str = "World") -> str: # noqa: N802
"""Greet someone."""
return f"Hello, {name}!"
app = mcp.streamable_http_app(
json_response=True,
host="127.0.0.1",
mcp._session_manager = StreamableHTTPSessionManager(
app=mcp._mcp_server,
json_response=mcp.settings.json_response,
stateless=mcp.settings.stateless_http,
security_settings=mcp.settings.transport_security,
session_idle_timeout=_IDLE_TIMEOUT_SECONDS,
)
mcp.session_manager.session_idle_timeout = _IDLE_TIMEOUT_SECONDS
ready_event.set()
uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")
mcp.run(transport="streamable-http")
async def _wait_for_server(url: str, timeout: float = 10.0) -> bool:
@@ -126,14 +130,10 @@ def _make_loop(tmp_path, *, mcp_servers: dict) -> AgentLoop:
@pytest.fixture(autouse=True)
def allow_loopback_mcp_urls(monkeypatch: pytest.MonkeyPatch):
"""The repro server runs on 127.0.0.1; allow nanobot to talk to it."""
class TestPinnedDNSAsyncTransport(security_network.Httpx2PinnedDNSAsyncTransport):
class TestPinnedDNSAsyncTransport(security_network.PinnedDNSAsyncTransport):
_resolver_lock = asyncio.Lock()
monkeypatch.setattr(
mcp_module,
"Httpx2PinnedDNSAsyncTransport",
TestPinnedDNSAsyncTransport,
)
monkeypatch.setattr(mcp_module, "PinnedDNSAsyncTransport", TestPinnedDNSAsyncTransport)
monkeypatch.setattr(
mcp_module,
"validate_url_target",
@@ -156,7 +156,7 @@ def allow_loopback_mcp_urls(monkeypatch: pytest.MonkeyPatch):
)
monkeypatch.setattr(
mcp_module,
"httpx2_env_proxy_mounts",
"httpx_env_proxy_mounts",
lambda: {},
)
+10 -17
View File
@@ -5,8 +5,9 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from mcp import MCPError
from mcp import types as mcp_types
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
from nanobot.agent.tools.mcp import (
MCPPromptWrapper,
@@ -36,16 +37,12 @@ class _FakeEndOfStreamError(Exception):
_FakeEndOfStreamError.__name__ = "EndOfStream"
def _session_terminated_error() -> MCPError:
return MCPError(-32000, "Session terminated")
def _session_terminated_error() -> McpError:
return McpError(ErrorData(code=-32000, message="Session terminated"))
def _connection_closed_error() -> MCPError:
return MCPError(-32000, "Connection closed")
def _session_not_found_error() -> MCPError:
return MCPError(-32600, "Session not found")
def _connection_closed_error() -> McpError:
return McpError(ErrorData(code=-32000, message="Connection closed"))
def test_is_transient_recognizes_closed_resource():
@@ -88,10 +85,6 @@ def test_is_session_terminated_recognizes_connection_closed_mcp_error():
assert _is_session_terminated(_connection_closed_error())
def test_is_session_terminated_recognizes_v2_session_not_found_error():
assert _is_session_terminated(_session_not_found_error())
# ---------------------------------------------------------------------------
# MCPToolWrapper retry behaviour
# ---------------------------------------------------------------------------
@@ -101,7 +94,7 @@ def _make_tool_def(name="test_tool"):
return SimpleNamespace(
name=name,
description="A test tool",
input_schema={"type": "object", "properties": {}},
inputSchema={"type": "object", "properties": {}},
)
@@ -422,10 +415,10 @@ async def test_prompt_fails_after_retry_exhausted():
@pytest.mark.asyncio
async def test_prompt_no_retry_on_mcp_error():
"""MCPError (application-level) should NOT trigger retry."""
"""McpError (application-level) should NOT trigger retry."""
session = AsyncMock()
session.get_prompt = AsyncMock(
side_effect=MCPError(-1, "not found")
side_effect=McpError(ErrorData(code=-1, message="not found"))
)
wrapper = MCPPromptWrapper(session, "test_server", _make_prompt_def())
@@ -450,7 +443,7 @@ async def test_prompt_no_retry_on_non_transient():
@pytest.mark.asyncio
async def test_prompt_reconnects_on_session_terminated():
"""Prompt should reconnect once before falling back to MCPError handling."""
"""Prompt should reconnect once before falling back to McpError handling."""
old_session = AsyncMock()
old_session.get_prompt = AsyncMock(side_effect=_session_terminated_error())
new_session = AsyncMock()
+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."""
+47
View File
@@ -310,3 +310,50 @@ async def test_runner_tool_error_preserves_tool_results_in_messages():
i for i, m in enumerate(result.messages) if m.get("role") == "tool"
]
assert all(ti > asst_tc_idx for ti in tool_indices)
@pytest.mark.asyncio
async def test_length_finish_with_blank_content_routes_to_length_recovery():
"""Regression test for #5133.
A response with finish_reason='length' and blank content (e.g. the model
spent its whole output budget on a tool call whose closing tag was
truncated) must take the length-recovery path, not the empty-response
retry path. Retrying the same prompt cannot recover from output-budget
exhaustion.
"""
from nanobot.agent.runner import AgentRunner
from nanobot.utils.runtime import LENGTH_RECOVERY_PROMPT
provider = MagicMock(spec=LLMProvider)
# First call: truncated (length) with blank content and a dropped tool call.
# Second call: normal completion so the loop can terminate.
provider.chat_with_retry = AsyncMock(side_effect=[
LLMResponse(
content="",
finish_reason="length",
tool_calls=[ToolCallRequest(id="call_1", name="exec", arguments={})],
usage={},
),
LLMResponse(content="done", finish_reason="stop", tool_calls=[], usage={}),
])
tools = MagicMock()
tools.get_definitions.return_value = []
runner = AgentRunner()
result = await runner.run(make_run_spec(provider,
initial_messages=[{"role": "user", "content": "do a long task"}],
tools=tools,
model="test-model",
max_iterations=5,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
))
# The runner must have injected a length-recovery prompt and continued,
# rather than exhausting empty-response retries into a generic apology.
user_msgs = [m.get("content") or "" for m in result.messages if m.get("role") == "user"]
assert any(LENGTH_RECOVERY_PROMPT in c for c in user_msgs), (
"expected a length-recovery message to be appended for a "
"finish_reason='length' response with blank content"
)
assert result.final_content == "done"
+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:
@@ -0,0 +1,53 @@
from __future__ import annotations
import asyncio
import gc
from unittest.mock import MagicMock
import pytest
def _make_loop(loop_factory):
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
return loop_factory(provider=provider)
def test_idle_agent_session_locks_are_released(loop_factory):
loop = _make_loop(loop_factory)
for index in range(1000):
lock = loop._get_session_lock(f"api:temporary-{index}")
del lock
gc.collect()
assert len(loop._session_locks) == 0
@pytest.mark.asyncio
async def test_waiter_keeps_agent_session_lock_alive(loop_factory):
loop = _make_loop(loop_factory)
owner_lock = loop._get_session_lock("api:shared")
await owner_lock.acquire()
waiter_started = asyncio.Event()
waiter_entered = asyncio.Event()
async def wait_for_lock() -> None:
lock = loop._get_session_lock("api:shared")
waiter_started.set()
async with lock:
waiter_entered.set()
waiter = asyncio.create_task(wait_for_lock())
await waiter_started.wait()
assert loop._get_session_lock("api:shared") is owner_lock
assert not waiter_entered.is_set()
owner_lock.release()
await waiter
del owner_lock
gc.collect()
assert "api:shared" not in loop._session_locks
+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)
+43
View File
@@ -83,6 +83,49 @@ async def test_handle_message_dm_sends_pairing_code(monkeypatch) -> None:
assert msg.metadata.get("_pairing_code") == "ABCD-EFGH"
@pytest.mark.asyncio
async def test_dm_during_transient_store_failure_keeps_approvals(
tmp_path, monkeypatch
) -> None:
"""An unapproved DM while pairing.json is unreadable must not wipe approvals.
The pairing store treated a transient OSError like corruption and returned
an empty store; the DM pairing path then persisted that empty view,
erasing every approved sender.
"""
import builtins
from pathlib import Path
from nanobot.pairing import store
path = tmp_path / "pairing.json"
monkeypatch.setattr(store, "_store_path", lambda: path)
code = store.generate_code("dummy", "friend")
store.approve_code(code)
channel = _DummyChannel({"allowFrom": []}, MessageBus())
real_open = builtins.open
def flaky_open(file, mode="r", *args, **kwargs):
try:
same = Path(file) == path
except TypeError:
same = False
if same and "r" in mode and "+" not in mode:
raise PermissionError(13, "temporarily locked", str(path))
return real_open(file, mode, *args, **kwargs)
with monkeypatch.context() as m:
m.setattr(builtins, "open", flaky_open)
await channel._handle_message(
sender_id="stranger", chat_id="chat1", content="hello", is_dm=True
)
assert channel._sent == []
assert store.is_approved("dummy", "friend") is True
@pytest.mark.asyncio
async def test_handle_message_group_ignores_unknown() -> None:
channel = _DummyChannel({"allowFrom": []}, MessageBus())
+63
View File
@@ -323,3 +323,66 @@ def test_pending_gc_drops_malformed_entries(tmp_path, monkeypatch):
)
monkeypatch.setattr(store, "_store_path", lambda: path)
assert store.list_pending() == []
def _fail_reads_of(monkeypatch, path):
"""Make reads of *path* raise like a transiently locked/busy file."""
import builtins
from pathlib import Path
real_open = builtins.open
def flaky_open(file, mode="r", *args, **kwargs):
try:
same = Path(file) == path
except TypeError:
same = False
if same and "r" in mode and "+" not in mode:
raise PermissionError(13, "temporarily locked", str(path))
return real_open(file, mode, *args, **kwargs)
monkeypatch.setattr(builtins, "open", flaky_open)
class TestTransientReadFailure:
"""A transient I/O failure is not corruption and must never wipe the store."""
def test_generate_code_does_not_wipe_approvals(self, tmp_path, monkeypatch):
"""An unapproved DM during a read blip previously erased every approval.
_load treated OSError like corruption and returned an empty store;
generate_code then unconditionally saved it, overwriting pairing.json
with no approved senders.
"""
code = store.generate_code("telegram", "123")
store.approve_code(code)
with monkeypatch.context() as m:
_fail_reads_of(m, store._store_path())
with pytest.raises(OSError):
store.generate_code("telegram", "stranger")
assert store.is_approved("telegram", "123") is True
def test_reads_fail_closed_without_crashing(self, tmp_path, monkeypatch):
code = store.generate_code("telegram", "123")
store.approve_code(code)
with monkeypatch.context() as m:
_fail_reads_of(m, store._store_path())
assert store.is_approved("telegram", "123") is False
assert store.list_pending() == []
assert store.get_approved("telegram") == []
assert store.is_approved("telegram", "123") is True
def test_approve_command_reports_store_unavailable(self, tmp_path, monkeypatch):
"""/pairing approve must fail loudly instead of claiming the code is invalid."""
code = store.generate_code("telegram", "123")
with monkeypatch.context() as m:
_fail_reads_of(m, store._store_path())
reply = store.handle_pairing_command("telegram", f"approve {code}")
assert "unavailable" in reply.lower()
assert store.approve_code(code) == ("telegram", "123")
+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
-14
View File
@@ -9,12 +9,9 @@ from unittest.mock import patch
import pytest
from nanobot.security.network import (
Httpx2PinnedDNSAsyncTransport,
PinnedDNSAsyncTransport,
configure_ssrf_whitelist,
contains_internal_url,
env_proxy_applies_to_url,
httpx2_env_proxy_mounts,
httpx_env_proxy_mounts,
is_loopback_host,
pin_resolved_url_dns,
@@ -267,17 +264,6 @@ def test_env_proxy_helpers_respect_no_proxy(monkeypatch):
assert any(transport is None for transport in mounts.values())
assert any(transport is not None for transport in mounts.values())
httpx2_mounts = httpx2_env_proxy_mounts()
assert any(transport is None for transport in httpx2_mounts.values())
assert any(transport is not None for transport in httpx2_mounts.values())
def test_httpx_transports_share_global_dns_pin_lock():
assert (
Httpx2PinnedDNSAsyncTransport._resolver_lock
is PinnedDNSAsyncTransport._resolver_lock
)
# ---------------------------------------------------------------------------
# contains_internal_url — shell command scanning
+84
View File
@@ -19,6 +19,8 @@ from nanobot.agent.tools.exec_session import (
ExecSessionManager,
ListExecSessionsTool,
WriteStdinTool,
_BoundedOutputBuffer,
_SessionPoll,
)
from nanobot.agent.tools.registry import is_tool_error_result
from nanobot.agent.tools.shell import ExecTool
@@ -143,6 +145,88 @@ def test_exec_session_accepts_max_output_tokens_alias(tmp_path):
assert "Exit code: 0" in result
def test_bounded_output_buffer_keeps_head_tail_and_exact_drop_count():
buffer = _BoundedOutputBuffer(10)
buffer.append("012345")
buffer.append("6789ABCDEF")
assert buffer.retained_chars == 10
assert buffer.drain() == ("01234BCDEF", 6)
assert buffer.retained_chars == 0
def test_exec_session_bounds_unpolled_stdout_and_stderr(tmp_path):
async def run() -> tuple[int, int, str, int]:
manager = ExecSessionManager()
tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
command = _python_command(
"import sys,time; time.sleep(0.05); "
"sys.stdout.write('OUT_HEAD' + 'o' * 200000 + 'OUT_TAIL'); "
"sys.stderr.write('ERR_HEAD' + 'e' * 200000 + 'ERR_TAIL')"
)
initial = await tool.execute(
command=command,
yield_time_ms=0,
max_output_chars=1000,
)
sid = _session_id(initial)
session = manager._sessions[sid]
await asyncio.wait_for(session.process.wait(), timeout=5)
await asyncio.wait_for(
asyncio.gather(session._stdout_task, session._stderr_task),
timeout=5,
)
retained_stdout = session._stdout.retained_chars
retained_stderr = session._stderr.retained_chars
poll = await manager.write(
session_id=sid,
chars=None,
close_stdin=False,
terminate=False,
yield_time_ms=0,
max_output_chars=1000,
)
return retained_stdout, retained_stderr, poll.output, poll.truncated_chars
retained_stdout, retained_stderr, output, truncated_chars = asyncio.run(run())
assert retained_stdout == 50000
assert retained_stderr == 50000
assert output.startswith("OUT_HEAD")
assert output.endswith("ERR_TAIL")
assert truncated_chars > 390000
def test_write_stdin_wait_for_keeps_aggregate_within_output_budget():
async def run() -> str:
manager = SimpleNamespace(
write=AsyncMock(side_effect=[
_SessionPoll(output="HEAD" + "a" * 596, done=False, exit_code=None),
_SessionPoll(output="b" * 600, done=False, exit_code=None),
_SessionPoll(output="c" * 590 + "TARGET", done=False, exit_code=None),
])
)
tool = WriteStdinTool(manager=manager)
return await tool._wait_for_output(
session_id="session",
chars=None,
close_stdin=False,
terminate=False,
wait_for="TARGET",
wait_timeout_ms=1000,
max_output_chars=1000,
)
result = asyncio.run(run())
assert result.startswith("HEAD")
assert "TARGET" in result
assert "(796 chars truncated from output)" in result
assert len(result) < 1100
def test_exec_one_shot_accepts_max_output_tokens_alias(tmp_path):
async def run() -> str:
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
+31 -34
View File
@@ -7,7 +7,7 @@ from contextlib import asynccontextmanager
from pathlib import Path
from types import ModuleType, SimpleNamespace
import httpx2 as httpx
import httpx
import pytest
import nanobot.agent.tools.mcp as mcp_mod
@@ -52,7 +52,7 @@ class _FakeBlobResourceContents:
class _FakeImageContent:
def __init__(self, data: str, mime_type: str = "image/png") -> None:
self.data = data
self.mime_type = mime_type
self.mimeType = mime_type
@pytest.fixture
@@ -111,7 +111,7 @@ def _fake_mcp_module(
@asynccontextmanager
async def _fake_streamable_http_client(_url: str, http_client=None):
yield object(), object()
yield object(), object(), object()
mod.ClientSession = _FakeClientSession
mod.StdioServerParameters = _FakeStdioServerParameters
@@ -133,13 +133,12 @@ def _fake_mcp_module(
shared_mod = ModuleType("mcp.shared")
exc_mod = ModuleType("mcp.shared.exceptions")
class _FakeMCPError(Exception):
class _FakeMcpError(Exception):
def __init__(self, code: int = -1, message: str = "error"):
self.error = SimpleNamespace(code=code, message=message)
super().__init__(message)
mod.MCPError = _FakeMCPError
exc_mod.MCPError = _FakeMCPError
exc_mod.McpError = _FakeMcpError
monkeypatch.setitem(sys.modules, "mcp.shared", shared_mod)
monkeypatch.setitem(sys.modules, "mcp.shared.exceptions", exc_mod)
@@ -148,7 +147,7 @@ def _make_wrapper(session: object, *, timeout: float = 0.1) -> MCPToolWrapper:
tool_def = SimpleNamespace(
name="demo",
description="demo tool",
input_schema={"type": "object", "properties": {}},
inputSchema={"type": "object", "properties": {}},
)
return MCPToolWrapper(session, "test", tool_def, tool_timeout=timeout)
@@ -186,7 +185,7 @@ def test_wrapper_preserves_non_nullable_unions() -> None:
tool_def = SimpleNamespace(
name="demo",
description="demo tool",
input_schema={
inputSchema={
"type": "object",
"properties": {
"value": {
@@ -208,7 +207,7 @@ def test_wrapper_normalizes_nullable_property_type_union() -> None:
tool_def = SimpleNamespace(
name="demo",
description="demo tool",
input_schema={
inputSchema={
"type": "object",
"properties": {
"name": {"type": ["string", "null"]},
@@ -225,7 +224,7 @@ def test_wrapper_normalizes_nullable_property_anyof() -> None:
tool_def = SimpleNamespace(
name="demo",
description="demo tool",
input_schema={
inputSchema={
"type": "object",
"properties": {
"name": {
@@ -250,7 +249,7 @@ def test_wrapper_hoists_recursive_local_refs_into_defs() -> None:
tool_def = SimpleNamespace(
name="search_dataset",
description="search tool",
input_schema={
inputSchema={
"type": "object",
"properties": {
"filter": {
@@ -283,7 +282,7 @@ def test_wrapper_hoists_root_self_ref_into_defs() -> None:
tool_def = SimpleNamespace(
name="tree",
description="tree tool",
input_schema={
inputSchema={
"type": "object",
"properties": {
"children": {"type": "array", "items": {"$ref": "#"}},
@@ -305,7 +304,7 @@ def test_wrapper_preserves_existing_defs_refs() -> None:
tool_def = SimpleNamespace(
name="demo",
description="demo tool",
input_schema={
inputSchema={
"type": "object",
"$defs": {"value": {"type": "string"}},
"properties": {"value": {"$ref": "#/$defs/value"}},
@@ -322,7 +321,7 @@ def test_wrapper_resolves_uri_encoded_json_pointer() -> None:
tool_def = SimpleNamespace(
name="demo",
description="demo tool",
input_schema={
inputSchema={
"type": "object",
"properties": {
"space name/value": {"type": "string"},
@@ -450,7 +449,7 @@ async def test_execute_wraps_mcp_is_error_result() -> None:
async def call_tool(_name: str, arguments: dict) -> object:
return SimpleNamespace(
content=[_FakeTextContent("Error: server-side MCP failure")],
is_error=True,
isError=True,
)
wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool))
@@ -495,7 +494,7 @@ async def test_execute_preserves_success_text_that_starts_with_error() -> None:
async def call_tool(_name: str, arguments: dict) -> object:
return SimpleNamespace(
content=[_FakeTextContent("Error: generated report successfully")],
is_error=False,
isError=False,
)
wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool))
@@ -623,7 +622,7 @@ def _make_tool_def(name: str) -> SimpleNamespace:
return SimpleNamespace(
name=name,
description=f"{name} tool",
input_schema={"type": "object", "properties": {}},
inputSchema={"type": "object", "properties": {}},
)
@@ -937,7 +936,7 @@ async def test_connect_mcp_servers_env_proxy_adds_proxy_mounts_and_keeps_pinned_
@asynccontextmanager
async def _capturing_streamable_http_client(_url: str, http_client=None):
assert http_client is not None
yield object(), object()
yield object(), object(), object()
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080")
monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1,::1")
@@ -945,11 +944,11 @@ async def test_connect_mcp_servers_env_proxy_adds_proxy_mounts_and_keeps_pinned_
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
monkeypatch.setattr(
mcp_mod,
"Httpx2PinnedDNSAsyncTransport",
"PinnedDNSAsyncTransport",
lambda: httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
)
monkeypatch.setattr(
"nanobot.security.network.httpx2.AsyncHTTPTransport",
"nanobot.security.network.httpx.AsyncHTTPTransport",
lambda **_kwargs: httpx.MockTransport(
lambda request: httpx.Response(200, request=request)
),
@@ -977,11 +976,11 @@ def test_mcp_http_clients_no_proxy_env_keeps_pinned_direct_route(monkeypatch):
monkeypatch.setenv("NO_PROXY", "mcp.example.com")
monkeypatch.setattr(
mcp_mod,
"Httpx2PinnedDNSAsyncTransport",
"PinnedDNSAsyncTransport",
lambda: httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
)
monkeypatch.setattr(
"nanobot.security.network.httpx2.AsyncHTTPTransport",
"nanobot.security.network.httpx.AsyncHTTPTransport",
lambda **_kwargs: httpx.MockTransport(
lambda request: httpx.Response(200, request=request)
),
@@ -1051,15 +1050,13 @@ async def test_connect_mcp_servers_http_clients_reject_unsafe_redirect_targets(
assert http_client is not None
used_transports.append("streamableHttp")
await http_client.get("https://example.com/start")
yield object(), object()
yield object(), object(), object()
monkeypatch.setattr(mcp_mod, "validate_url_target", _validate)
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
# Keep the redirect exercise isolated from host-level proxy settings.
monkeypatch.setattr(mcp_mod, "httpx2_env_proxy_mounts", lambda: {})
monkeypatch.setattr(
mcp_mod,
"Httpx2PinnedDNSAsyncTransport",
"PinnedDNSAsyncTransport",
lambda **_kwargs: httpx.MockTransport(_handler),
)
monkeypatch.setattr(mcp_mod.httpx, "AsyncClient", _async_client_with_mock_transport)
@@ -1141,13 +1138,13 @@ async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
@asynccontextmanager
async def _capturing_streamable_http_client(_url: str, http_client=None):
captured["timeout"] = http_client.timeout
yield object(), object()
yield object(), object(), object()
monkeypatch.setattr(mcp_mod, "validate_url_target", _validate)
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
monkeypatch.setattr(
mcp_mod,
"Httpx2PinnedDNSAsyncTransport",
"PinnedDNSAsyncTransport",
lambda: httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
)
monkeypatch.setattr(
@@ -1388,10 +1385,10 @@ async def test_prompt_wrapper_execute_handles_timeout() -> None:
@pytest.mark.asyncio
async def test_prompt_wrapper_execute_handles_mcp_error() -> None:
from mcp import MCPError
from mcp.shared.exceptions import McpError
async def get_prompt(name: str, arguments: dict | None = None) -> object:
raise MCPError(code=42, message="invalid argument")
raise McpError(code=42, message="invalid argument")
wrapper = _make_prompt_wrapper(SimpleNamespace(get_prompt=get_prompt))
result = await wrapper.execute()
@@ -1513,7 +1510,7 @@ def test_tool_wrapper_sanitizes_name() -> None:
tool_def = SimpleNamespace(
name="My Tool",
description="tool with spaces",
input_schema={"type": "object", "properties": {}},
inputSchema={"type": "object", "properties": {}},
)
wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), "srv", tool_def)
assert wrapper.name == "mcp_srv_My_Tool"
@@ -1544,7 +1541,7 @@ def test_tool_wrapper_preserves_original_name_for_mcp_call() -> None:
tool_def = SimpleNamespace(
name="My Tool",
description="tool with spaces",
input_schema={"type": "object", "properties": {}},
inputSchema={"type": "object", "properties": {}},
)
wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), "srv", tool_def)
# The sanitized API-facing name differs from the original MCP name
@@ -1622,12 +1619,12 @@ def test_long_server_name_tools_are_matched_by_server_name() -> None:
tool_def = SimpleNamespace(
name="search",
description="search tool",
input_schema={"type": "object", "properties": {}},
inputSchema={"type": "object", "properties": {}},
)
other_tool_def = SimpleNamespace(
name="search",
description="other search tool",
input_schema={"type": "object", "properties": {}},
inputSchema={"type": "object", "properties": {}},
)
wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), server_name, tool_def)
other_wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), "other", other_tool_def)
+22
View File
@@ -310,3 +310,25 @@ class TestNestedRepoProtection:
assert result is False
assert not (workspace / ".git").exists()
class TestCommitIdEncoding:
"""Commit ids must be usable with git, not hex-of-hex."""
def test_auto_commit_returns_the_real_short_sha(self, git, tmp_path):
(tmp_path / "MEMORY.md").write_text("- a fact\n", encoding="utf-8")
sha = git.auto_commit("memory update")
expected = subprocess.run(
["git", "-C", str(tmp_path), "log", "-1", "--format=%h", "--abbrev=8"],
capture_output=True, text=True, check=True,
).stdout.strip()
assert sha == expected
def test_a_real_git_sha_resolves(self, git, tmp_path):
(tmp_path / "MEMORY.md").write_text("- a fact\n", encoding="utf-8")
git.auto_commit("memory update")
real = subprocess.run(
["git", "-C", str(tmp_path), "log", "-1", "--format=%h", "--abbrev=8"],
capture_output=True, text=True, check=True,
).stdout.strip()
assert git._resolve_sha(real) is not None
+36
View File
@@ -696,6 +696,42 @@ def test_replay_preserves_local_trigger_source_metadata(tmp_path, monkeypatch) -
assert msgs[0]["source"] == {"kind": "local_trigger", "label": "PR review"}
def test_replay_preserves_automation_source_metadata_on_streamed_reply(
tmp_path,
monkeypatch,
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-streamed-cron-source"
source = {"kind": "cron", "label": "Repo check"}
for record in (
{
"event": "delta",
"chat_id": "t-streamed-cron-source",
"text": "Repo ",
"source": source,
},
{
"event": "delta",
"chat_id": "t-streamed-cron-source",
"text": "clean.",
"source": source,
},
{
"event": "stream_end",
"chat_id": "t-streamed-cron-source",
"source": source,
},
{"event": "turn_end", "chat_id": "t-streamed-cron-source"},
):
append_transcript_object(key, record)
msgs = replay_transcript_to_ui_messages(read_transcript_lines(key))
assert msgs[0]["content"] == "Repo clean."
assert msgs[0]["source"] == source
def test_replay_preserves_legacy_trigger_source_metadata(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-trigger-source"
+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")
+55
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from types import SimpleNamespace
@@ -14,6 +15,60 @@ from nanobot.webui.token_usage import (
)
def _write_state(tmp_path, days: dict) -> None:
state_dir = tmp_path / "webui"
state_dir.mkdir(parents=True, exist_ok=True)
(state_dir / "token-usage.json").write_text(
json.dumps({"days": days}), encoding="utf-8"
)
def test_payload_tolerates_malformed_persisted_day_keys(tmp_path, monkeypatch) -> None:
"""Day keys that are not real dates must not break settings payloads.
normalize_token_usage_state only length-checks day keys, so a hand-edited
10-char key survives reads and atomic rewrites; token_usage_payload then
parsed it with an unguarded fromisoformat, failing every /api/settings and
/api/settings/usage request until the file was fixed by hand.
"""
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
_write_state(tmp_path, {
"not-a-dat3": {"total_tokens": 7, "requests": 1},
"2026-13-01": {"total_tokens": 9, "requests": 1},
"2026-06-02": {"total_tokens": 5, "requests": 1},
})
payload = token_usage_payload(
timezone_name="UTC",
now=datetime(2026, 6, 3, 12, 0, tzinfo=timezone.utc),
)
assert payload["total_tokens"] == 5
assert payload["total_tokens_30d"] == 5
assert payload["requests_30d"] == 1
assert payload["active_days_30d"] == 1
def test_record_scrubs_malformed_day_keys(tmp_path, monkeypatch) -> None:
"""Rewrites drop malformed day keys instead of persisting them forever."""
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
_write_state(tmp_path, {
"not-a-dat3": {"total_tokens": 7, "requests": 1},
"2026-06-02": {"total_tokens": 5, "requests": 1},
})
record_token_usage(
{"prompt_tokens": 1, "completion_tokens": 1},
timezone_name="UTC",
now=datetime(2026, 6, 3, 12, 0, tzinfo=timezone.utc),
)
raw = json.loads((tmp_path / "webui" / "token-usage.json").read_text(encoding="utf-8"))
assert "not-a-dat3" not in raw["days"]
assert "2026-06-02" in raw["days"]
assert "2026-06-03" in raw["days"]
def test_record_token_usage_aggregates_by_local_day(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
+66 -10
View File
@@ -37,6 +37,12 @@ import {
import { displayTitle } from "@/lib/chat-groups";
import { deriveTitle } from "@/lib/format";
import { NanobotClient } from "@/lib/nanobot-client";
import {
isQuickChatKey,
QUICK_CHAT_ID,
QUICK_CHAT_KEY,
quickChatSession,
} from "@/lib/quick-chat";
import { ClientProvider, useClient } from "@/providers/ClientProvider";
import type {
BootstrapResponse,
@@ -225,6 +231,9 @@ function readShellRoute(): ShellRoute {
if (path === "/skills") {
return { view: "skills", activeKey, settingsSection: "skills" };
}
if (path === "/quick-chat") {
return { view: "chat", activeKey: QUICK_CHAT_KEY, settingsSection: "overview" };
}
if (path.startsWith("/chat/")) {
const encoded = path.slice("/chat/".length);
try {
@@ -241,6 +250,7 @@ function readShellRoute(): ShellRoute {
function shellRouteHash(route: ShellRoute): string {
if (route.view === "chat") {
if (isQuickChatKey(route.activeKey)) return "#/quick-chat";
return route.activeKey
? `#/chat/${encodeURIComponent(route.activeKey)}`
: "#/new";
@@ -947,8 +957,16 @@ function Shell({
deleteChat,
getSessionAutomations,
} = useSessions();
const regularSessions = useMemo(
() => sessions.filter((session) => !isQuickChatKey(session.key)),
[sessions],
);
const quickSession = useMemo(
() => quickChatSession(sessions.find((session) => isQuickChatKey(session.key))),
[sessions],
);
const { state: sidebarState, update: updateSidebarState } =
useSidebarState(sessions, !loading);
useSidebarState(regularSessions, !loading);
const initialRouteRef = useRef<ShellRoute | null>(null);
if (!initialRouteRef.current) initialRouteRef.current = readShellRoute();
const [activeKey, setActiveKey] = useState<string | null>(
@@ -1114,8 +1132,10 @@ function Shell({
const activeSession = useMemo<ChatSummary | null>(() => {
if (!activeKey) return null;
if (isQuickChatKey(activeKey)) return quickSession;
return sessions.find((s) => s.key === activeKey) ?? null;
}, [sessions, activeKey]);
}, [sessions, activeKey, quickSession]);
const quickChatActive = isQuickChatKey(activeKey);
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
const activeChatId = activeSession?.chatId ?? null;
@@ -1130,6 +1150,9 @@ function Shell({
});
}, [activeChatId]);
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
if (quickChatActive) {
return workspaces?.default_scope ?? null;
}
if (activeChatId && workspaceOverrides[activeChatId]) {
return workspaceOverrides[activeChatId];
}
@@ -1141,6 +1164,7 @@ function Shell({
activeChatId,
activeSession?.workspaceScope,
draftWorkspaceScope,
quickChatActive,
workspaceOverrides,
workspaces?.default_scope,
]);
@@ -1161,7 +1185,10 @@ function Shell({
useEffect(() => {
if (loading) return;
const knownChatIds = new Set(sessions.map((session) => session.chatId));
const knownChatIds = new Set([
QUICK_CHAT_ID,
...sessions.map((session) => session.chatId),
]);
setUpdatedChatIds((current) => {
const next = new Set(
Array.from(current).filter((chatId) => knownChatIds.has(chatId)),
@@ -1176,6 +1203,7 @@ function Shell({
useEffect(() => {
if (loading || !activeKey) return;
if (isQuickChatKey(activeKey)) return;
if (sessions.some((session) => session.key === activeKey)) return;
const currentRoute = readShellRoute();
navigate(
@@ -1417,6 +1445,18 @@ function Shell({
setMobileSidebarOpen(false);
}, [navigate]);
const onOpenQuickChat = useCallback(() => {
setDraftWorkspaceScope(null);
setWorkspaceError(null);
setSessionSearchOpen(false);
navigate({
view: "chat",
activeKey: QUICK_CHAT_KEY,
settingsSection: "overview",
});
setMobileSidebarOpen(false);
}, [navigate]);
const onNewChatInProject = useCallback(
(projectPath: string, projectName: string) => {
const base = workspaces?.default_scope ?? activeWorkspaceScope;
@@ -1682,6 +1722,7 @@ function Shell({
setMobileSidebarOpen(false);
const nextKey = (() => {
if (!activeKey) return null;
if (isQuickChatKey(activeKey)) return activeKey;
if (sessions.some((session) => session.key === activeKey)) return activeKey;
return sessions[0]?.key ?? null;
})();
@@ -1773,7 +1814,10 @@ function Shell({
});
}, [client, t]);
const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh);
const onTurnEnd = useDeferredTitleRefresh(
quickChatActive ? null : activeSession,
refresh,
);
const onConfirmDelete = useCallback(async () => {
if (!pendingDelete) return;
@@ -1863,7 +1907,9 @@ function Shell({
});
}, []);
const headerTitle = activeSession
const headerTitle = quickChatActive
? t("sidebar.quickChat")
: activeSession
? sidebarState.title_overrides[activeSession.key] ||
activeSession.title ||
deriveTitle(activeSession.preview, t("chat.newChat"))
@@ -1900,9 +1946,12 @@ function Shell({
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
const sidebarProps = {
sessions,
activeKey,
sessions: regularSessions,
activeKey: view === "chat" ? activeKey : null,
loading,
quickChatActive: view === "chat" && quickChatActive,
newChatActive: view === "chat" && activeKey === null,
onOpenQuickChat,
onNewChat,
onSelect: onSelectChat,
onRequestDelete,
@@ -2065,7 +2114,7 @@ function Shell({
<SessionSearchDialog
open
onOpenChange={setSessionSearchOpen}
sessions={sessions}
sessions={regularSessions}
activeKey={activeKey}
loading={loading}
titleOverrides={sidebarState.title_overrides}
@@ -2090,7 +2139,7 @@ function Shell({
onToggleSidebar={toggleSidebar}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
onForkChat={onForkChat}
onForkChat={quickChatActive ? undefined : onForkChat}
onTurnEnd={onTurnEnd}
theme={theme}
onToggleTheme={toggle}
@@ -2099,13 +2148,20 @@ function Shell({
hideHeader={false}
workspaceScope={activeWorkspaceScope}
workspaceDefaultScope={workspaces?.default_scope ?? null}
workspaceControls={workspaces?.controls ?? null}
workspaceControls={
quickChatActive ? null : (workspaces?.controls ?? null)
}
workspaceScopeDisabled={activeChatRunning}
workspaceError={workspaceError}
onWorkspaceScopeChange={applyWorkspaceScope}
settingsSnapshot={settingsSnapshot}
onOpenModelSettings={onOpenModelSettings}
skills={skills}
allowConversationReset={!quickChatActive}
showSessionInfo={!quickChatActive}
emptyStateGreeting={
quickChatActive ? t("quickChat.greeting") : undefined
}
/>
</div>
{view !== "chat" && (
+12 -85
View File
@@ -1,7 +1,6 @@
import {
memo,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -25,6 +24,10 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SIDEBAR_SELECTION_ITEM_CLASS,
SidebarSelectionHighlight,
} from "@/components/SidebarSelectionHighlight";
import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format";
import {
COLLAPSED_CHATS_VISIBLE_COUNT,
@@ -106,9 +109,6 @@ export const ChatList = memo(function ChatList({
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
const listContentRef = useRef<HTMLDivElement>(null);
const activeRowRef = useRef<HTMLDivElement>(null);
const activeHighlightRef = useRef<HTMLDivElement>(null);
const activeHighlightSurfaceRef = useRef<HTMLDivElement>(null);
const highlightVisibleRef = useRef(false);
const labels = useMemo<ChatGroupLabels>(() => ({
pinned: t("chat.groups.pinned"),
all: t("chat.groups.all"),
@@ -163,74 +163,6 @@ export const ChatList = memo(function ChatList({
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
}, [showArchived, sort]);
useLayoutEffect(() => {
let resetTransitionFrame: number | null = null;
const updateHighlight = () => {
const content = listContentRef.current;
const row = activeRowRef.current;
const highlight = activeHighlightRef.current;
const surface = activeHighlightSurfaceRef.current;
if (!highlight || !surface) return;
if (!content || !row) {
surface.style.opacity = "0";
surface.style.transform = "scale(0.97)";
highlightVisibleRef.current = false;
return;
}
const shouldFloatIn = !highlightVisibleRef.current;
if (shouldFloatIn) {
highlight.style.transitionProperty = "none";
}
const contentRect = content.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
highlight.style.width = `${rowRect.width}px`;
highlight.style.height = `${rowRect.height}px`;
highlight.style.transform = `translate3d(${rowRect.left - contentRect.left}px, ${
rowRect.top - contentRect.top
}px, 0)`;
if (shouldFloatIn) {
void highlight.offsetWidth;
}
surface.style.opacity = "1";
surface.style.transform = "scale(1)";
highlightVisibleRef.current = true;
if (shouldFloatIn) {
resetTransitionFrame = window.requestAnimationFrame(() => {
highlight.style.removeProperty("transition-property");
resetTransitionFrame = null;
});
}
};
updateHighlight();
const resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(updateHighlight);
if (resizeObserver) {
if (listContentRef.current) resizeObserver.observe(listContentRef.current);
if (activeRowRef.current) resizeObserver.observe(activeRowRef.current);
}
window.addEventListener("resize", updateHighlight);
return () => {
if (resetTransitionFrame !== null) {
window.cancelAnimationFrame(resetTransitionFrame);
}
activeHighlightRef.current?.style.removeProperty("transition-property");
resizeObserver?.disconnect();
window.removeEventListener("resize", updateHighlight);
};
}, [activeKey, density, limitedGroups, showPreviews, showTimestamps]);
if (loading && sessions.length === 0) {
return (
<div className="px-3 py-6 text-[12px] text-muted-foreground">
@@ -333,7 +265,8 @@ export const ChatList = memo(function ChatList({
ref={active ? activeRowRef : undefined}
data-chat-row={s.key}
className={cn(
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px] transition-colors",
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px]",
SIDEBAR_SELECTION_ITEM_CLASS,
compact ? "min-h-7" : "min-h-8",
active
? "text-sidebar-accent-foreground"
@@ -475,18 +408,12 @@ export const ChatList = memo(function ChatList({
</button>
</div>
) : null}
<div
ref={activeHighlightRef}
data-testid="active-chat-highlight"
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 z-0 !mt-0 transition-[transform,width,height] duration-300 ease-out will-change-transform motion-reduce:transition-none"
>
<div
ref={activeHighlightSurfaceRef}
data-testid="active-chat-highlight-surface"
className="h-full w-full scale-[0.97] rounded-xl bg-sidebar-foreground/[0.055] opacity-0 transition-[opacity,transform] duration-200 ease-out motion-reduce:transition-none dark:bg-white/[0.07]"
/>
</div>
<SidebarSelectionHighlight
containerRef={listContentRef}
targetRef={activeRowRef}
activeId={activeKey}
scope="sessions"
/>
</div>
</div>
);
+54 -7
View File
@@ -1,8 +1,14 @@
import { useState, type ReactNode } from "react";
import {
type ReactNode,
type RefObject,
useRef,
useState,
} from "react";
import {
Archive,
Brain,
CalendarClock,
MessageCircle,
Menu,
Search,
Settings,
@@ -13,6 +19,10 @@ import { useTranslation } from "react-i18next";
import { ChatList } from "@/components/ChatList";
import { ConnectionBadge } from "@/components/ConnectionBadge";
import {
SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
SidebarSelectionHighlight,
} from "@/components/SidebarSelectionHighlight";
import { Button } from "@/components/ui/button";
import type {
ChatSummary,
@@ -24,6 +34,9 @@ interface SidebarProps {
sessions: ChatSummary[];
activeKey: string | null;
loading: boolean;
quickChatActive: boolean;
newChatActive: boolean;
onOpenQuickChat: () => void;
onNewChat: () => void;
onSelect: (key: string) => void;
onRequestDelete: (key: string, label: string) => void;
@@ -82,6 +95,15 @@ export function Sidebar(props: SidebarProps) {
const collapsed = Boolean(props.collapsed);
const toggleLabel = t("thread.header.toggleSidebar");
const newChatShortcut = newChatShortcutLabel();
const actionListRef = useRef<HTMLDivElement>(null);
const activeActionRef = useRef<HTMLButtonElement>(null);
const activeActionId = props.quickChatActive
? "quick-chat"
: props.newChatActive
? "new-chat"
: props.activeUtility
? `utility:${props.activeUtility}`
: null;
return (
<nav
@@ -134,15 +156,26 @@ export function Sidebar(props: SidebarProps) {
</div>
<div
ref={actionListRef}
className={cn(
"space-y-1.5 px-2 pb-2",
"relative space-y-1.5 px-2 pb-2",
collapsed && "flex w-14 flex-col items-center px-0",
)}
>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.quickChat")}
onClick={props.onOpenQuickChat}
active={props.quickChatActive}
selectionRef={activeActionRef}
icon={<MessageCircle className="h-4 w-4" />}
/>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.newChat")}
onClick={props.onNewChat}
active={props.newChatActive}
selectionRef={activeActionRef}
icon={<SquarePen className="h-4 w-4" />}
shortcut={newChatShortcut}
ariaKeyShortcuts="Meta+Shift+O Control+Shift+O"
@@ -159,6 +192,7 @@ export function Sidebar(props: SidebarProps) {
onClick={props.onOpenApps}
onIntent={props.onSettingsIntent}
active={props.activeUtility === "apps"}
selectionRef={activeActionRef}
icon={<Blocks className="h-4 w-4" />}
/>
<SidebarActionButton
@@ -167,6 +201,7 @@ export function Sidebar(props: SidebarProps) {
onClick={props.onOpenSkills}
onIntent={props.onSettingsIntent}
active={props.activeUtility === "skills"}
selectionRef={activeActionRef}
icon={<Brain className="h-4 w-4" />}
/>
<SidebarActionButton
@@ -175,6 +210,7 @@ export function Sidebar(props: SidebarProps) {
onClick={props.onOpenAutomations}
onIntent={props.onSettingsIntent}
active={props.activeUtility === "automations"}
selectionRef={activeActionRef}
icon={<CalendarClock className="h-4 w-4" />}
/>
{props.archivedCount ? (
@@ -185,6 +221,12 @@ export function Sidebar(props: SidebarProps) {
icon={<Archive className="h-4 w-4" />}
/>
) : null}
<SidebarSelectionHighlight
containerRef={actionListRef}
targetRef={activeActionRef}
activeId={activeActionId}
scope="actions"
/>
</div>
<div
className={cn(
@@ -255,6 +297,7 @@ function SidebarActionButton({
shortcut,
ariaKeyShortcuts,
onIntent,
selectionRef,
}: {
collapsed: boolean;
label: string;
@@ -265,13 +308,15 @@ function SidebarActionButton({
shortcut?: string;
ariaKeyShortcuts?: string;
onIntent?: () => void;
selectionRef?: RefObject<HTMLButtonElement>;
}) {
const title = shortcut ? `${label} (${shortcut})` : collapsed ? label : undefined;
return (
<Button
ref={active ? selectionRef : undefined}
type="button"
variant="ghost"
variant={null}
aria-label={label}
aria-current={active ? "page" : undefined}
aria-keyshortcuts={ariaKeyShortcuts}
@@ -280,12 +325,14 @@ function SidebarActionButton({
onFocus={onIntent}
onPointerEnter={onIntent}
className={cn(
"touch-target group h-8 min-w-0 gap-2 overflow-hidden rounded-full font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground",
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out",
"touch-target group h-8 min-w-0 gap-2 overflow-hidden rounded-xl font-medium",
SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
collapsed
? "w-9 justify-center gap-0 rounded-xl px-0"
? "w-9 justify-center gap-0 px-0"
: "w-full justify-start gap-2 px-3 text-[12.5px]",
active && "bg-sidebar-accent text-sidebar-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
active
? "text-sidebar-accent-foreground"
: "text-sidebar-foreground/85 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
className,
)}
>
@@ -0,0 +1,90 @@
import {
type RefObject,
useLayoutEffect,
useRef,
} from "react";
interface SidebarSelectionHighlightProps {
containerRef: RefObject<HTMLElement>;
targetRef: RefObject<HTMLElement>;
activeId: string | null;
scope: string;
}
export const SIDEBAR_SELECTION_ITEM_CLASS =
"relative z-[1] transition-[color] duration-150 ease-out motion-reduce:transition-none";
export const SIDEBAR_SELECTION_ACTION_ITEM_CLASS =
"relative z-[1] transition-[width,padding,color] [transition-duration:300ms,300ms,150ms] ease-out motion-reduce:transition-none";
export function SidebarSelectionHighlight({
containerRef,
targetRef,
activeId,
scope,
}: SidebarSelectionHighlightProps) {
const highlightRef = useRef<HTMLDivElement>(null);
const positionedRef = useRef(false);
useLayoutEffect(() => {
const highlight = highlightRef.current;
const container = containerRef.current;
const target = targetRef.current;
let restoreTransitionFrame: number | null = null;
const position = () => {
if (!highlight) return;
if (!activeId || !container || !target) {
highlight.style.opacity = "0";
positionedRef.current = false;
return;
}
const firstPosition = !positionedRef.current;
if (firstPosition) highlight.style.transitionProperty = "none";
const containerRect = container.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
highlight.style.width = `${targetRect.width}px`;
highlight.style.height = `${targetRect.height}px`;
highlight.style.transform = `translate3d(${targetRect.left - containerRect.left}px, ${
targetRect.top - containerRect.top
}px, 0)`;
highlight.style.opacity = "1";
positionedRef.current = true;
if (firstPosition) {
restoreTransitionFrame = window.requestAnimationFrame(() => {
highlight.style.removeProperty("transition-property");
restoreTransitionFrame = null;
});
}
};
position();
const resizeObserver =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(position);
if (container) resizeObserver?.observe(container);
if (target) resizeObserver?.observe(target);
window.addEventListener("resize", position);
return () => {
if (restoreTransitionFrame !== null) {
window.cancelAnimationFrame(restoreTransitionFrame);
}
highlight?.style.removeProperty("transition-property");
resizeObserver?.disconnect();
window.removeEventListener("resize", position);
};
});
return (
<div
ref={highlightRef}
data-testid={`${scope}-selection-highlight`}
data-active-id={activeId ?? undefined}
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 z-0 !mt-0 rounded-xl bg-sidebar-foreground/[0.055] opacity-0 transition-[transform,width,height] duration-300 ease-out will-change-transform motion-reduce:transition-none dark:bg-white/[0.07]"
/>
);
}
+17 -3
View File
@@ -65,6 +65,10 @@ import { useTranslation } from "react-i18next";
import { channelUiPresentation } from "@/channel-plugins/registry";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import {
SIDEBAR_SELECTION_ITEM_CLASS,
SidebarSelectionHighlight,
} from "@/components/SidebarSelectionHighlight";
import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings";
import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap";
import { ToggleButton } from "@/components/settings/ToggleButton";
@@ -2497,6 +2501,8 @@ function SettingsSidebar({
hostChromeInset?: boolean;
}) {
const { t } = useTranslation();
const desktopNavRef = useRef<HTMLDivElement>(null);
const activeNavItemRef = useRef<HTMLButtonElement>(null);
const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.key === activeSection)
?? SETTINGS_NAV_ITEMS[0];
const ActiveIcon = activeItem.icon;
@@ -2569,19 +2575,21 @@ function SettingsSidebar({
</DropdownMenuContent>
</DropdownMenu>
<div className="hidden space-y-1 lg:block">
<div ref={desktopNavRef} className="relative hidden space-y-1 lg:block">
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
const active = key === activeSection;
return (
<button
ref={active ? activeNavItemRef : undefined}
key={key}
type="button"
aria-current={active ? "page" : undefined}
onClick={() => onSelectSection(key)}
className={cn(
"touch-target flex h-9 w-full items-center gap-2 rounded-[10px] px-2.5 text-left text-[13px] font-medium transition-colors",
"touch-target flex h-9 w-full items-center gap-2 rounded-xl px-2.5 text-left text-[13px] font-medium",
SIDEBAR_SELECTION_ITEM_CLASS,
active
? "bg-sidebar-accent text-foreground"
? "text-sidebar-accent-foreground"
: "text-muted-foreground/78 hover:bg-muted/45 hover:text-foreground",
)}
>
@@ -2592,6 +2600,12 @@ function SettingsSidebar({
</button>
);
})}
<SidebarSelectionHighlight
containerRef={desktopNavRef}
targetRef={activeNavItemRef}
activeId={activeSection}
scope="settings"
/>
</div>
</nav>
+17 -5
View File
@@ -315,6 +315,9 @@ interface ThreadShellProps {
settingsSnapshot?: SettingsPayload | null;
onOpenModelSettings?: () => void;
skills?: SkillSummary[];
allowConversationReset?: boolean;
showSessionInfo?: boolean;
emptyStateGreeting?: string;
}
function toModelBadgeLabel(modelName: string | null): string | null {
@@ -597,6 +600,9 @@ export function ThreadShell({
settingsSnapshot = null,
onOpenModelSettings,
skills = [],
allowConversationReset = true,
showSessionInfo = true,
emptyStateGreeting,
}: ThreadShellProps) {
const { t } = useTranslation();
const chatId = session?.chatId ?? null;
@@ -622,6 +628,12 @@ export function ThreadShell({
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const availableSlashCommands = useMemo(
() => allowConversationReset
? slashCommands
: slashCommands.filter((command) => command.command !== "/new"),
[allowConversationReset, slashCommands],
);
const cliApps = useInstalledSettingItems({
getToken,
eventName: CLI_APPS_CHANGED_EVENT,
@@ -1374,7 +1386,7 @@ export function ThreadShell({
fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant={showHeroComposer ? "hero" : "thread"}
slashCommands={slashCommands}
slashCommands={availableSlashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
skills={skills}
@@ -1416,7 +1428,7 @@ export function ThreadShell({
fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant="hero"
slashCommands={slashCommands}
slashCommands={availableSlashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
skills={skills}
@@ -1442,10 +1454,10 @@ export function ThreadShell({
</div>
) : (
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<HeroGreeting text={t(heroGreetingKey)} />
<HeroGreeting text={emptyStateGreeting ?? t(heroGreetingKey)} />
</div>
);
const sessionInfoAction = historyKey ? (
const sessionInfoAction = historyKey && showSessionInfo ? (
<SessionInfoPopover sessionKey={historyKey} token={token} title={title} />
) : undefined;
const promptNavigatorAction = historyKey ? (
@@ -1488,7 +1500,7 @@ export function ThreadShell({
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
slashCommands={slashCommands}
slashCommands={availableSlashCommands}
forkBoundaryMessageCount={forkBoundaryMessageCount}
hasMoreBefore={hasMoreBefore}
loadingOlder={loadingOlder}
+28 -4
View File
@@ -37,7 +37,7 @@ interface ActiveAssistantCursor {
}
type PendingStreamEvent =
| { kind: "delta"; text: string; turn: UIMessageTurnFields }
| { kind: "delta"; text: string; turn: UIMessageTurnFields; source?: UIMessage["source"] }
| { kind: "reasoning"; text: string; turn: UIMessageTurnFields };
type UIMessageTurnFields = Pick<UIMessage, "turnId" | "turnPhase" | "turnSeq">;
@@ -778,7 +778,12 @@ export function useNanobotStream(
}, []);
const appendAnswerChunk = useCallback(
(prev: UIMessage[], chunk: string, turn: UIMessageTurnFields = {}): UIMessage[] => {
(
prev: UIMessage[],
chunk: string,
turn: UIMessageTurnFields = {},
source?: UIMessage["source"],
): UIMessage[] => {
let next = prev;
let targetIndex = resolveActiveAssistantIndex(next, turn);
@@ -809,6 +814,7 @@ export function useNanobotStream(
content: target.content + chunk,
isStreaming: true,
...turn,
...(source ? { source } : {}),
};
closedAssistantStreamIdsRef.current.delete(merged.id);
activeAssistantRef.current = { id: merged.id, index: targetIndex };
@@ -823,7 +829,7 @@ export function useNanobotStream(
let next = prev;
for (const event of events) {
if (event.kind === "delta") {
next = appendAnswerChunk(next, event.text, event.turn);
next = appendAnswerChunk(next, event.text, event.turn, event.source);
} else {
if (closeActiveAssistantStream()) clearActivitySegment();
next = attachReasoningChunk(
@@ -843,6 +849,7 @@ export function useNanobotStream(
closeAnswerSegment?: boolean;
finalAnswerText?: string;
turn?: UIMessageTurnFields;
source?: UIMessage["source"];
}) => {
if (streamFrameRef.current !== null) {
window.cancelAnimationFrame(streamFrameRef.current);
@@ -855,7 +862,8 @@ export function useNanobotStream(
const events = pendingStreamEventsRef.current;
const finalAnswerText = options?.finalAnswerText;
const turn = options?.turn ?? {};
if (events.length === 0 && finalAnswerText === undefined) {
const source = options?.source;
if (events.length === 0 && finalAnswerText === undefined && source === undefined) {
if (options?.closeAnswerSegment) closeActiveAssistantStream();
return;
}
@@ -873,6 +881,7 @@ export function useNanobotStream(
content: finalAnswerText,
isStreaming: true,
...turn,
...(source ? { source } : {}),
};
next = replaceMessageAt(next, targetIndex, merged);
if (!options?.closeAnswerSegment) {
@@ -890,6 +899,7 @@ export function useNanobotStream(
content: finalAnswerText,
isStreaming: true,
...turn,
...(source ? { source } : {}),
createdAt: Date.now(),
},
];
@@ -900,6 +910,18 @@ export function useNanobotStream(
buffer.current = { messageId: id };
}
}
} else if (source) {
const targetIndex =
resolveActiveAssistantIndex(next, turn)
?? findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current, turn);
if (targetIndex !== null) {
const target = next[targetIndex];
next = replaceMessageAt(next, targetIndex, {
...target,
...turn,
source,
});
}
}
if (options?.closeAnswerSegment) closeActiveAssistantStream();
return next;
@@ -1027,6 +1049,7 @@ export function useNanobotStream(
kind: "delta",
text: chunk,
turn: turnFieldsFromEvent(ev, "answer"),
source: ev.source,
});
schedulePendingStreamFlush();
return;
@@ -1054,6 +1077,7 @@ export function useNanobotStream(
closeAnswerSegment: !mergeNext,
...(typeof ev.text === "string" ? { finalAnswerText: ev.text } : {}),
turn,
source: ev.source,
});
if (suppressStreamUntilTurnEndRef.current) return;
if (ev.resuming) {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "Sidebar navigation",
"collapse": "Collapse sidebar",
"quickChat": "Quick Chat",
"newChat": "New topic",
"searchAria": "Search",
"searchPlaceholder": "Search",
@@ -60,6 +61,9 @@
"title": "Skills"
}
},
"quickChat": {
"greeting": "What's on your mind?"
},
"settings": {
"backToChat": "Back to chat",
"sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "Navegación de la barra lateral",
"collapse": "Contraer barra lateral",
"quickChat": "Chat rápido",
"newChat": "Nuevo tema",
"searchAria": "Buscar",
"searchPlaceholder": "Buscar",
@@ -60,6 +61,9 @@
"title": "Habilidades"
}
},
"quickChat": {
"greeting": "¿Qué tienes en mente?"
},
"settings": {
"backToChat": "Volver al chat",
"sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "Navigation de la barre latérale",
"collapse": "Réduire la barre latérale",
"quickChat": "Discussion rapide",
"newChat": "Nouveau sujet",
"searchAria": "Rechercher",
"searchPlaceholder": "Rechercher",
@@ -60,6 +61,9 @@
"title": "Compétences"
}
},
"quickChat": {
"greeting": "De quoi avez-vous envie de parler ?"
},
"settings": {
"backToChat": "Retour au chat",
"sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "Navigasi bilah samping",
"collapse": "Ciutkan sidebar",
"quickChat": "Obrolan cepat",
"newChat": "Topik baru",
"searchAria": "Cari",
"searchPlaceholder": "Cari",
@@ -60,6 +61,9 @@
"title": "Skill"
}
},
"quickChat": {
"greeting": "Apa yang sedang kamu pikirkan?"
},
"settings": {
"backToChat": "Kembali ke chat",
"sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "サイドバーのナビゲーション",
"collapse": "サイドバーを閉じる",
"quickChat": "クイックチャット",
"newChat": "新しいトピック",
"searchAria": "検索",
"searchPlaceholder": "検索",
@@ -60,6 +61,9 @@
"title": "スキル"
}
},
"quickChat": {
"greeting": "何について話しますか?"
},
"settings": {
"backToChat": "チャットに戻る",
"sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "사이드바 탐색",
"collapse": "사이드바 접기",
"quickChat": "빠른 채팅",
"newChat": "새 주제",
"searchAria": "검색",
"searchPlaceholder": "검색",
@@ -60,6 +61,9 @@
"title": "스킬"
}
},
"quickChat": {
"greeting": "무슨 이야기를 나눠볼까요?"
},
"settings": {
"backToChat": "채팅으로 돌아가기",
"sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "Navegação da barra lateral",
"collapse": "Recolher barra lateral",
"quickChat": "Chat rápido",
"newChat": "Novo tópico",
"searchAria": "Buscar",
"searchPlaceholder": "Buscar",
@@ -60,6 +61,9 @@
"title": "Skills"
}
},
"quickChat": {
"greeting": "O que você está pensando?"
},
"settings": {
"backToChat": "Voltar para a conversa",
"sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "Điều hướng thanh bên",
"collapse": "Thu gọn thanh bên",
"quickChat": "Trò chuyện nhanh",
"newChat": "Chủ đề mới",
"searchAria": "Tìm kiếm",
"searchPlaceholder": "Tìm kiếm",
@@ -60,6 +61,9 @@
"title": "Kỹ năng"
}
},
"quickChat": {
"greeting": "Bạn đang nghĩ gì?"
},
"settings": {
"backToChat": "Quay lại chat",
"sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "侧边栏导航",
"collapse": "收起侧边栏",
"quickChat": "随便聊聊",
"newChat": "新建话题",
"searchAria": "搜索",
"searchPlaceholder": "搜索",
@@ -60,6 +61,9 @@
"title": "技能"
}
},
"quickChat": {
"greeting": "想聊点什么?"
},
"settings": {
"backToChat": "返回聊天",
"sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "側邊欄導覽",
"collapse": "收合側邊欄",
"quickChat": "輕鬆聊聊",
"newChat": "新增話題",
"searchAria": "搜尋",
"searchPlaceholder": "搜尋",
@@ -60,6 +61,9 @@
"title": "技能"
}
},
"quickChat": {
"greeting": "想聊點什麼?"
},
"settings": {
"backToChat": "返回聊天",
"sidebar": {
+22
View File
@@ -0,0 +1,22 @@
import type { ChatSummary } from "@/lib/types";
export const QUICK_CHAT_ID = "quick-chat";
export const QUICK_CHAT_KEY = `websocket:${QUICK_CHAT_ID}`;
export function isQuickChatKey(key: string | null): boolean {
return key === QUICK_CHAT_KEY;
}
export function quickChatSession(persisted?: ChatSummary): ChatSummary {
return {
key: QUICK_CHAT_KEY,
channel: "websocket",
chatId: QUICK_CHAT_ID,
createdAt: persisted?.createdAt ?? null,
updatedAt: persisted?.updatedAt ?? null,
preview: persisted?.preview ?? "",
modelPreset: persisted?.modelPreset ?? null,
runStartedAt: persisted?.runStartedAt ?? null,
workspaceScope: persisted?.workspaceScope ?? null,
};
}
+4
View File
@@ -1185,12 +1185,16 @@ export type InboundEvent =
chat_id: string;
text: string;
stream_id?: string;
/** Lightweight provenance for proactive streamed assistant messages. */
source?: UIMessageSource;
} & InboundTurnMetadata)
| ({
event: "stream_end";
chat_id: string;
stream_id?: string;
text?: string;
/** Lightweight provenance for proactive streamed assistant messages. */
source?: UIMessageSource;
/** This answer segment ended, but the active agent turn will continue. */
resuming?: boolean;
/** The next answer segment continues this same assistant message. */
+137 -2
View File
@@ -349,6 +349,107 @@ describe("App layout", () => {
).toBeTruthy();
});
it("opens a single fixed Quick Chat without provisioning a new session", async () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
const quickChatButton = within(sidebar).getByRole("button", {
name: "Quick Chat",
});
const newTopicButton = within(sidebar).getByRole("button", {
name: "New topic",
});
const actionHighlight = within(sidebar).getByTestId(
"actions-selection-highlight",
);
fireEvent.click(quickChatButton);
expect(window.location.hash).toBe("#/quick-chat");
expect(quickChatButton).toHaveAttribute("aria-current", "page");
expect(newTopicButton).not.toHaveAttribute("aria-current");
expect(quickChatButton).not.toHaveClass("bg-sidebar-accent");
expect(quickChatButton).toHaveClass("transition-[width,padding,color]");
expect(actionHighlight).toHaveAttribute("data-active-id", "quick-chat");
expect(
within(sidebar).queryByTestId("actions-selection-highlight-surface"),
).not.toBeInTheDocument();
await waitFor(() =>
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining(
"/api/sessions/websocket%3Aquick-chat/webui-thread",
),
expect.anything(),
),
);
expect(createChatSpy).not.toHaveBeenCalled();
expect(document.title).toBe("Quick Chat · nanobot");
expect(screen.getByText("What's on your mind?")).toBeInTheDocument();
fireEvent.click(newTopicButton);
expect(window.location.hash).toBe("#/new");
expect(newTopicButton).toHaveAttribute("aria-current", "page");
expect(quickChatButton).not.toHaveAttribute("aria-current");
expect(actionHighlight).toHaveAttribute("data-active-id", "new-chat");
expect(within(sidebar).queryAllByRole("button", { current: "page" })).toHaveLength(1);
});
it("restores Quick Chat before it has a persisted session", async () => {
window.history.replaceState(null, "", "/#/quick-chat");
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
expect(window.location.hash).toBe("#/quick-chat");
await waitFor(() =>
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining(
"/api/sessions/websocket%3Aquick-chat/webui-thread",
),
expect.anything(),
),
);
expect(
within(screen.getByRole("navigation", { name: "Sidebar navigation" }))
.getByRole("button", { name: "Quick Chat" }),
).toHaveAttribute("aria-current", "page");
});
it("keeps persisted Quick Chat out of the topic list and topic search", async () => {
mockSessions = [
{
key: "websocket:quick-chat",
channel: "websocket",
chatId: "quick-chat",
createdAt: "2026-07-30T08:00:00Z",
updatedAt: "2026-07-30T08:05:00Z",
preview: "A private casual message",
},
{
key: "websocket:project-chat",
channel: "websocket",
chatId: "project-chat",
createdAt: "2026-07-30T08:00:00Z",
updatedAt: "2026-07-30T08:05:00Z",
preview: "Project roadmap",
},
];
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
expect(within(sidebar).getByText("Project roadmap")).toBeInTheDocument();
expect(within(sidebar).queryByText("A private casual message")).not.toBeInTheDocument();
fireEvent.click(within(sidebar).getByRole("button", { name: "Search" }));
const dialog = await screen.findByRole("dialog", { name: "Search" });
expect(within(dialog).getByText("Project roadmap")).toBeInTheDocument();
expect(within(dialog).queryByText("A private casual message")).not.toBeInTheDocument();
});
it("restores the Settings route after a restart fallback hash", async () => {
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
@@ -2128,16 +2229,41 @@ describe("App layout", () => {
expect(window.location.hash).toBe("#/settings");
const settingsNav = screen.getByRole("navigation", { name: "Settings sections" });
fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" }));
const overviewButton = within(settingsNav).getByRole("button", {
name: "Overview",
exact: true,
});
const modelsButton = within(settingsNav).getByRole("button", {
name: "Models",
exact: true,
});
const settingsHighlight = within(settingsNav).getByTestId(
"settings-selection-highlight",
);
expect(overviewButton).toHaveAttribute("aria-current", "page");
expect(overviewButton).not.toHaveClass("bg-sidebar-accent");
expect(overviewButton).toHaveClass("transition-[color]");
expect(settingsHighlight).toHaveAttribute("data-active-id", "overview");
fireEvent.click(modelsButton);
expect(await screen.findByText("Model presets")).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Models" })).not.toBeInTheDocument();
expect(window.location.hash).toBe("#/settings?section=models");
expect(modelsButton).toHaveAttribute("aria-current", "page");
expect(settingsHighlight).toHaveAttribute("data-active-id", "models");
fireEvent.click(within(settingsNav).getByRole("button", { name: "Voice" }));
const voiceButton = within(settingsNav).getByRole("button", {
name: "Voice",
exact: true,
});
fireEvent.click(voiceButton);
expect(await screen.findByRole("heading", { name: "Voice input" })).toBeInTheDocument();
expect(window.location.hash).toBe("#/settings?section=voice");
expect(voiceButton).toHaveAttribute("aria-current", "page");
expect(settingsHighlight).toHaveAttribute("data-active-id", "voice");
});
it("transitions between Apps and Skills without replacing the sidebar", async () => {
@@ -2163,6 +2289,11 @@ describe("App layout", () => {
"aria-current",
"page",
);
expect(within(sidebar).getByTestId("actions-selection-highlight")).toHaveAttribute(
"data-active-id",
"utility:apps",
);
expect(within(sidebar).queryAllByRole("button", { current: "page" })).toHaveLength(1);
expect(screen.getByTestId("settings-section-transition")).toHaveAttribute(
"data-settings-section",
"apps",
@@ -2190,6 +2321,10 @@ describe("App layout", () => {
"aria-current",
"page",
);
expect(within(sidebar).getByTestId("actions-selection-highlight")).toHaveAttribute(
"data-active-id",
"utility:skills",
);
expect(document.title).toBe("Skills · nanobot");
});
+10 -12
View File
@@ -220,7 +220,7 @@ describe("ChatList", () => {
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument();
});
it("floats a borderless highlight in, then slides it between selected topics", () => {
it("positions one background highlight, then slides it between selected topics", () => {
let revealFrame: FrameRequestCallback | null = null;
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
revealFrame = callback;
@@ -259,14 +259,15 @@ describe("ChatList", () => {
/>,
);
const highlight = screen.getByTestId("active-chat-highlight");
const surface = screen.getByTestId("active-chat-highlight-surface");
expect(surface).toHaveClass(
const highlight = screen.getByTestId("sessions-selection-highlight");
expect(highlight).toHaveClass(
"bg-sidebar-foreground/[0.055]",
"transition-[opacity,transform]",
"transition-[transform,width,height]",
"motion-reduce:transition-none",
);
expect(surface).toHaveStyle("opacity: 0; transform: scale(0.97)");
expect(highlight).toHaveStyle("opacity: 0");
expect(screen.queryByTestId("sessions-selection-highlight-surface"))
.not.toBeInTheDocument();
rerender(
<ChatList
@@ -277,18 +278,15 @@ describe("ChatList", () => {
const activeButton = screen.getByTitle("Active topic");
expect(activeButton).toHaveAttribute("aria-current", "page");
expect(activeButton.parentElement).toHaveClass("transition-[color]");
expect(activeButton.parentElement).not.toHaveClass("transition-colors");
expect(activeButton.parentElement).not.toHaveClass(
"bg-sidebar-accent",
"shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
);
expect(highlight).toHaveClass(
"transition-[transform,width,height]",
"motion-reduce:transition-none",
);
expect(highlight).toHaveStyle(
"width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); transition-property: none",
"width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1; transition-property: none",
);
expect(surface).toHaveStyle("opacity: 1; transform: scale(1)");
revealFrame?.(0);
expect(highlight.style.transitionProperty).toBe("");
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import {
isQuickChatKey,
QUICK_CHAT_ID,
QUICK_CHAT_KEY,
quickChatSession,
} from "@/lib/quick-chat";
describe("Quick Chat identity", () => {
it("uses one stable websocket session", () => {
expect(QUICK_CHAT_ID).toBe("quick-chat");
expect(QUICK_CHAT_KEY).toBe("websocket:quick-chat");
expect(isQuickChatKey(QUICK_CHAT_KEY)).toBe(true);
expect(isQuickChatKey("websocket:another-chat")).toBe(false);
});
it("keeps persisted metadata behind the fixed identity", () => {
expect(quickChatSession({
key: "websocket:quick-chat",
channel: "websocket",
chatId: "quick-chat",
createdAt: "2026-07-30T08:00:00Z",
updatedAt: "2026-07-30T08:05:00Z",
preview: "hello",
modelPreset: "fast",
})).toMatchObject({
key: QUICK_CHAT_KEY,
channel: "websocket",
chatId: QUICK_CHAT_ID,
createdAt: "2026-07-30T08:00:00Z",
updatedAt: "2026-07-30T08:05:00Z",
preview: "hello",
modelPreset: "fast",
});
});
});
+68
View File
@@ -3369,6 +3369,74 @@ describe("ThreadShell", () => {
expect(screen.getByRole("option", { name: /\/history/i })).toBeInTheDocument();
});
it("removes session-management affordances from a fixed conversation", async () => {
const client = makeClient();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/commands")) {
return httpJson({
commands: [
{
command: "/new",
title: "New chat",
description: "Reset this chat and start a fresh conversation.",
icon: "square-pen",
lifecycle: "finalize_active_turn",
accepts_args: false,
},
{
command: "/history",
title: "Show conversation history",
description: "Print the last N persisted messages.",
icon: "history",
arg_hint: "[n]",
lifecycle: "side_channel",
accepts_args: true,
},
],
});
}
return {
ok: false,
status: 404,
json: async () => ({}),
};
}),
);
render(
wrap(
client,
<ThreadShell
session={session("quick-chat")}
title="Quick Chat"
onToggleSidebar={() => {}}
allowConversationReset={false}
showSessionInfo={false}
/>,
),
);
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
"/api/commands",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
));
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "/" },
});
expect(screen.getByRole("option", { name: /\/history/i })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: /\/new/i })).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Session details" }),
).not.toBeInTheDocument();
});
it("does not bring back welcome cards when image mode is enabled", async () => {
const client = makeClient();
const settings = modelSettings("deepseek-v4-pro", "deepseek");
+57
View File
@@ -314,6 +314,63 @@ describe("useNanobotStream", () => {
});
});
it("preserves proactive automation source metadata on streamed assistant messages", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-cron-stream", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
const source = { kind: "cron", label: "Repo check" };
act(() => {
fake.emit("chat-cron-stream", {
event: "delta",
chat_id: "chat-cron-stream",
text: "Repo ",
source,
});
fake.emit("chat-cron-stream", {
event: "stream_end",
chat_id: "chat-cron-stream",
source,
});
fake.emit("chat-cron-stream", {
event: "turn_end",
chat_id: "chat-cron-stream",
});
});
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "Repo ",
isStreaming: false,
source,
});
});
it("preserves proactive automation source metadata on stream_end final text", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-cron-stream-end", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
const source = { kind: "cron", label: "Repo check" };
act(() => {
fake.emit("chat-cron-stream-end", {
event: "stream_end",
chat_id: "chat-cron-stream-end",
text: "Repo clean.",
source,
});
});
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "Repo clean.",
isStreaming: true,
source,
});
});
it("does not start streaming from completed trailing activity after an answer", () => {
const fake = fakeClient();
const initialMessages = [