Compare commits

..
Author SHA1 Message Date
chengyongru 720f14661f refactor(providers): declare Responses capabilities 2026-08-01 12:13:01 +08:00
chengyongruandGitHub cdb75f8e7d feat(providers): support DeepSeek Responses API (#5197) 2026-08-01 11:53:51 +08:00
chengyongruandGitHub 971b977a84 fix(weixin): recover refreshed state after session expiry (#5196) 2026-08-01 00:28:21 +08:00
54650332fb fix(slack): scope channel thread openers to their own session
A top-level channel message that opens a thread fell back to the
channel-wide session, because the session key required `raw_thread_ts` —
which Slack only sets on messages that already arrived inside a thread.
Every new thread therefore began life in one shared channel session and
only became thread-scoped from its first reply onward, so unrelated
threads saw each other's opening turns.

Key off `thread_ts` instead. It is set both for messages arriving inside
a thread and for channel messages that `reply_in_thread` opens a thread
for. DM roots never get a `thread_ts`, so they keep the default per-chat
session and the DM routing from 82c5083 is preserved; with
`reply_in_thread` disabled no thread exists and the channel session is
still used.

This restores the per-thread isolation introduced in #1048.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:47:26 +08:00
chengyongruandGitHub 172fe4f991 fix(webui): preserve user scroll ownership near tail (#5193) 2026-07-31 23:37:26 +08:00
shixi-liandchengyongru dda9b61b1e fix(config): install timezone data on all platforms 2026-07-31 19:55:22 +08:00
39 changed files with 771 additions and 410 deletions
+1 -2
View File
@@ -356,8 +356,7 @@ 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.
nanobot preserves Responses conversation state automatically for OpenAI Responses, OpenAI Codex, Azure OpenAI, DeepSeek V4 Flash, 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.
+2
View File
@@ -231,6 +231,8 @@ 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. 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.
DeepSeek is the model-level exception in the OpenAI-compatible provider: `deepseek-v4-flash` automatically uses DeepSeek's native Responses API, while `deepseek-v4-pro` remains on Chat Completions.
### Custom OpenAI-Compatible Endpoint
The `custom` provider fits one OpenAI-compatible endpoint that is not represented by a named provider.
+5 -6
View File
@@ -493,12 +493,11 @@ class SlackChannel(BaseChannel):
except Exception as e:
self.logger.debug("reactions_add failed: {}", e)
# Thread-scoped session key whenever the user is in a real thread
# (raw_thread_ts is set). DM threads get their own session, separate
# from the DM root, so context doesn't bleed across thread boundaries.
session_key = (
f"slack:{chat_id}:{thread_ts}" if thread_ts and raw_thread_ts else None
)
# Thread-scoped session key whenever the turn lives in a thread: either the
# message arrived inside one (raw_thread_ts) or reply_in_thread opens a new
# thread for this channel message. DM roots have no thread_ts and keep the
# default per-chat session, so context doesn't bleed across thread boundaries.
session_key = f"slack:{chat_id}:{thread_ts}" if thread_ts else None
media_paths: list[str] = []
file_markers: list[str] = []
for file_info in _as_json_list(event.get("files")) or []:
@@ -555,6 +555,113 @@ async def test_dm_thread_message_keeps_thread_ts_and_threaded_session() -> None:
assert kwargs["metadata"]["slack"]["thread_ts"] == "1700000000.000100"
def _channel_mention_request(envelope_id: str, ts: str) -> SimpleNamespace:
return SimpleNamespace(
type="events_api",
envelope_id=envelope_id,
payload={
"event": {
"type": "app_mention",
"user": "U1",
"channel": "C123",
"text": "<@UBOT> hello",
"ts": ts,
}
},
)
@pytest.mark.asyncio
async def test_channel_root_message_uses_thread_scoped_session() -> None:
"""A channel mention that opens a thread belongs to that thread's session."""
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
channel._bot_user_id = "UBOT"
channel._web_client = _FakeAsyncWebClient()
channel._handle_message = AsyncMock() # type: ignore[method-assign]
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
req = _channel_mention_request("env-c1", "1700000000.000100")
await channel._on_socket_request(client, req)
channel._handle_message.assert_awaited_once()
kwargs = channel._handle_message.await_args.kwargs
assert kwargs["session_key"] == "slack:C123:1700000000.000100"
assert kwargs["metadata"]["slack"]["thread_ts"] == "1700000000.000100"
@pytest.mark.asyncio
async def test_channel_root_messages_do_not_share_one_session() -> None:
"""Two threads opened in the same channel must not collapse into one session."""
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
channel._bot_user_id = "UBOT"
channel._web_client = _FakeAsyncWebClient()
channel._handle_message = AsyncMock() # type: ignore[method-assign]
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
first = _channel_mention_request("env-c1", "1700000000.000100")
second = _channel_mention_request("env-c2", "1700000000.000200")
await channel._on_socket_request(client, first)
await channel._on_socket_request(client, second)
session_keys = [call.kwargs["session_key"] for call in channel._handle_message.await_args_list]
assert session_keys == [
"slack:C123:1700000000.000100",
"slack:C123:1700000000.000200",
]
@pytest.mark.asyncio
async def test_channel_root_message_without_reply_in_thread_uses_channel_session() -> None:
"""With reply_in_thread disabled no thread is opened, so the channel session is used."""
channel = SlackChannel(SlackConfig(enabled=True, reply_in_thread=False), MessageBus())
channel._bot_user_id = "UBOT"
channel._web_client = _FakeAsyncWebClient()
channel._handle_message = AsyncMock() # type: ignore[method-assign]
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
req = _channel_mention_request("env-c3", "1700000000.000300")
await channel._on_socket_request(client, req)
channel._handle_message.assert_awaited_once()
kwargs = channel._handle_message.await_args.kwargs
assert kwargs["session_key"] is None
assert kwargs["metadata"]["slack"]["thread_ts"] is None
@pytest.mark.asyncio
async def test_channel_thread_reply_keeps_thread_session() -> None:
"""A reply inside a channel thread stays in the session opened by the root message."""
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
channel._bot_user_id = "UBOT"
channel._web_client = _FakeAsyncWebClient()
channel._handle_message = AsyncMock() # type: ignore[method-assign]
channel._with_thread_context = AsyncMock(return_value="hello") # type: ignore[method-assign]
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
req = SimpleNamespace(
type="events_api",
envelope_id="env-c4",
payload={
"event": {
"type": "app_mention",
"user": "U1",
"channel": "C123",
"text": "<@UBOT> follow up",
"ts": "1700000000.000400",
"thread_ts": "1700000000.000100",
}
},
)
await channel._on_socket_request(client, req)
channel._handle_message.assert_awaited_once()
kwargs = channel._handle_message.await_args.kwargs
assert kwargs["session_key"] == "slack:C123:1700000000.000100"
@pytest.mark.asyncio
async def test_slack_slash_command_skips_thread_context() -> None:
channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus())
+25 -2
View File
@@ -230,9 +230,30 @@ class WeixinChannel(BaseChannel):
self.logger.error("Failed to load Weixin account state", exc_info=True)
return False
def _save_state(self) -> None:
def _save_state(self, *, force: bool = False) -> None:
state_file = self._get_state_dir() / "account.json"
with suppress(Exception):
if not force and state_file.exists():
persisted: object = None
try:
persisted = json.loads(state_file.read_text())
except Exception:
persisted = None
persisted_token = ""
if isinstance(persisted, dict):
persisted_mapping = cast(dict[str, object], persisted)
persisted_token = str(persisted_mapping.get("token", "") or "")
configured_token_is_authoritative: bool = bool(self.config.token) and (
self._token == self.config.token
)
if (
persisted_token
and persisted_token != self._token
and not configured_token_is_authoritative
):
# A concurrent QR login may have committed a newer token.
# Never let an older runtime snapshot overwrite it.
return
data = {
"token": self._token,
"get_updates_buf": self._get_updates_buf,
@@ -489,7 +510,7 @@ class WeixinChannel(BaseChannel):
self._token = token
if base_url:
self.config.base_url = base_url
self._save_state()
self._save_state(force=True)
async def connect_close_client(self) -> None:
self._running = False
@@ -613,6 +634,8 @@ class WeixinChannel(BaseChannel):
remaining = self._session_pause_remaining_s()
if remaining > 0:
await asyncio.sleep(remaining)
if not self.config.token:
self._load_state()
return
body: dict[str, Any] = {
@@ -98,6 +98,80 @@ def test_save_and_load_state_persists_context_tokens(tmp_path) -> None:
assert restored._context_tokens == {"wx-user": "ctx-1"}
def test_save_state_preserves_token_committed_by_another_instance(tmp_path) -> None:
channel = WeixinChannel(
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
MessageBus(),
)
channel._token = "old-token"
channel._save_state()
replacement = {
"token": "new-token",
"base_url": "https://new.example",
"get_updates_buf": "",
"context_tokens": {},
"typing_tickets": {},
}
(tmp_path / "account.json").write_text(json.dumps(replacement), encoding="utf-8")
channel._get_updates_buf = "stale-cursor"
channel._save_state()
assert json.loads((tmp_path / "account.json").read_text()) == replacement
def test_save_state_force_overwrites_replaced_token(tmp_path) -> None:
channel = WeixinChannel(
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
MessageBus(),
)
(tmp_path / "account.json").write_text(json.dumps({"token": "old-token"}), encoding="utf-8")
channel.connect_commit_account(token="new-token", base_url="https://new.example")
saved = json.loads((tmp_path / "account.json").read_text())
assert saved["token"] == "new-token"
assert saved["base_url"] == "https://new.example"
def test_save_state_persists_explicit_config_token_over_stale_state(tmp_path) -> None:
channel = WeixinChannel(
WeixinConfig(
enabled=True,
allow_from=["*"],
token="configured-token",
state_dir=str(tmp_path),
),
MessageBus(),
)
channel._token = "configured-token"
channel._get_updates_buf = "current-cursor"
(tmp_path / "account.json").write_text(
json.dumps({"token": "stale-token", "get_updates_buf": "stale-cursor"}),
encoding="utf-8",
)
channel._save_state()
saved = json.loads((tmp_path / "account.json").read_text())
assert saved["token"] == "configured-token"
assert saved["get_updates_buf"] == "current-cursor"
def test_save_state_with_empty_runtime_token_preserves_persisted_account(tmp_path) -> None:
channel = WeixinChannel(
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
MessageBus(),
)
persisted = {"token": "persisted-token", "get_updates_buf": "persisted-cursor"}
(tmp_path / "account.json").write_text(json.dumps(persisted), encoding="utf-8")
channel._save_state()
assert json.loads((tmp_path / "account.json").read_text()) == persisted
@pytest.mark.asyncio
async def test_process_message_deduplicates_inbound_ids() -> None:
channel, bus = _make_channel()
@@ -462,6 +536,56 @@ async def test_poll_once_pauses_session_on_expired_errcode() -> None:
assert channel._session_pause_remaining_s() > 0
@pytest.mark.asyncio
async def test_poll_once_reloads_refreshed_state_after_session_pause(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
channel = WeixinChannel(
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
MessageBus(),
)
channel._token = "old-token"
channel._save_state()
(tmp_path / "account.json").write_text(
json.dumps({"token": "new-token", "base_url": "https://new.example"}),
encoding="utf-8",
)
channel._session_pause_until = time.time() + 10
monkeypatch.setattr(weixin_mod.asyncio, "sleep", AsyncMock())
await channel._poll_once()
assert channel._token == "new-token"
assert channel.config.base_url == "https://new.example"
@pytest.mark.asyncio
async def test_poll_once_keeps_explicit_token_after_session_pause(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
channel = WeixinChannel(
WeixinConfig(
enabled=True,
allow_from=["*"],
token="configured-token",
state_dir=str(tmp_path),
),
MessageBus(),
)
channel._token = "configured-token"
(tmp_path / "account.json").write_text(
json.dumps({"token": "stale-token", "base_url": "https://stale.example"}),
encoding="utf-8",
)
channel._session_pause_until = time.time() + 10
monkeypatch.setattr(weixin_mod.asyncio, "sleep", AsyncMock())
await channel._poll_once()
assert channel._token == "configured-token"
assert channel.config.base_url == "https://ilinkai.weixin.qq.com"
@pytest.mark.asyncio
async def test_qr_login_refreshes_expired_qr_and_then_succeeds(
no_qr_poll_delay,
+1 -4
View File
@@ -24,7 +24,6 @@ class ProviderSnapshot:
@dataclass(frozen=True)
class _ProviderSetup:
model: str
provider_name: str
provider_config: ProviderConfig | None
spec: ProviderSpec | None
backend: str
@@ -100,7 +99,6 @@ def _resolve_provider_setup(
return _ProviderSetup(
model=model,
provider_name=provider_name,
provider_config=p,
spec=spec,
backend=backend,
@@ -136,7 +134,6 @@ def _make_provider_core(
model=model,
)
model = setup.model
provider_name = setup.provider_name
p = setup.provider_config
spec = setup.spec
backend = setup.backend
@@ -201,7 +198,7 @@ def _make_provider_core(
extra_headers=_provider_extra_headers(spec, p),
spec=spec,
extra_body=p.extra_body if p else None,
api_type=p.api_type if p and provider_name == "openai" else "auto",
api_type=p.api_type if p else "auto",
extra_query=p.extra_query if p else None,
proxy=p.proxy if p else None,
)
+50 -26
View File
@@ -49,7 +49,7 @@ from nanobot.providers.openai_responses import (
if TYPE_CHECKING:
from openai import AsyncOpenAI as AsyncOpenAIType
from nanobot.providers.registry import ProviderSpec
from nanobot.providers.registry import ProviderSpec, ResponsesCapabilities
# Module-level placeholder — set lazily by _ensure_client on first real
# use, or replaced by tests via ``patch(...)``. Kept as a plain name so
@@ -470,7 +470,12 @@ class OpenAICompatProvider(LLMProvider):
self.extra_headers = extra_headers or {}
self._spec = spec
self._extra_body = extra_body or {}
self._api_type = api_type if spec and spec.name == "openai" else "auto"
responses = spec.responses if spec is not None else None
self._api_type = (
api_type
if responses is not None and responses.allows_api_type_override
else "auto"
)
self._extra_query = extra_query or {}
self._proxy = proxy or None
self._native_compaction_available = True
@@ -958,30 +963,36 @@ class OpenAICompatProvider(LLMProvider):
model: str | None,
reasoning_effort: str | None,
) -> bool:
"""Use Responses API only for direct OpenAI requests that benefit from it."""
"""Choose Responses for providers/models that explicitly support it."""
if self._api_type == "chat_completions":
return False
if self._spec and self._spec.name not in ("openai", "github_copilot"):
capabilities = self._responses_capabilities()
if capabilities is None:
return False
model_name = self._request_model_name(model or self.default_model).lower()
if self._api_type == "responses":
# Explicit configuration means Responses is mandatory; do not
# consult the circuit breaker or fall back to Chat Completions.
return True
if self._spec is None or self._spec.name != "github_copilot":
if not _is_direct_openai_base(self._effective_base):
return False
if (
capabilities.requires_direct_openai_base
and not _is_direct_openai_base(self._effective_base)
):
return False
model_name = (model or self.default_model).lower()
wants = False
if reasoning_effort and reasoning_effort.lower() != "none":
wants = True
elif any(token in model_name for token in ("gpt-5", "o1", "o3", "o4")):
wants = True
if not wants:
explicitly_supported = capabilities.matches_model(model_name)
wants_auto_route = capabilities.auto_route and (
(reasoning_effort is not None and reasoning_effort.lower() != "none")
or any(token in model_name for token in ("gpt-5", "o1", "o3", "o4"))
)
if not explicitly_supported and not wants_auto_route:
return False
return self._responses_circuit_allows_probe(model, reasoning_effort)
def _responses_capabilities(self) -> ResponsesCapabilities | None:
return self._spec.responses if self._spec is not None else None
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"
@@ -1004,14 +1015,20 @@ class OpenAICompatProvider(LLMProvider):
def supports_native_compaction(self, model: str | None = None) -> bool:
"""Enable server compaction only on direct OpenAI Responses endpoints."""
_ = model
capabilities = self._responses_capabilities()
if (
not self._native_compaction_available
or self._api_type == "chat_completions"
or capabilities is None
or not capabilities.supports_native_compaction
):
return False
if self._spec is not None and self._spec.name != "openai":
if (
capabilities.requires_direct_openai_base
and not _is_direct_openai_base(self._effective_base)
):
return False
return _is_direct_openai_base(self._effective_base)
return True
def _responses_circuit_allows_probe(
self,
@@ -1099,11 +1116,16 @@ class OpenAICompatProvider(LLMProvider):
self._sanitize_empty_content(sanitized_state.pending_messages)
)
)
capabilities = self._responses_capabilities()
preserve_reasoning = (
capabilities is not None and capabilities.reasoning_replay == "plaintext"
)
instructions, input_items, replayed = prepare_responses_input(
sanitized_messages,
state=sanitized_state,
provider=self._responses_state_provider(),
model=model_name,
preserve_reasoning=preserve_reasoning,
)
body: dict[str, Any] = {
@@ -1128,10 +1150,15 @@ class OpenAICompatProvider(LLMProvider):
"compact_threshold": compact_threshold,
}]
if self._supports_temperature(model_name, reasoning_effort):
supports_temperature = self._supports_temperature(model_name, reasoning_effort)
if supports_temperature:
body["temperature"] = temperature
if not self._supports_temperature(model_name, reasoning_effort):
if (
not supports_temperature
and capabilities is not None
and capabilities.reasoning_replay == "encrypted"
):
body["include"] = ["reasoning.encrypted_content"]
if reasoning_effort and reasoning_effort.lower() != "none":
body["reasoning"] = {"effort": reasoning_effort}
@@ -1752,10 +1779,8 @@ class OpenAICompatProvider(LLMProvider):
self._record_responses_success(model, reasoning_effort)
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;
# falling back to /chat/completions cannot succeed and would
# hide the real error.
capabilities = self._responses_capabilities()
if capabilities is not None and not capabilities.allows_chat_fallback:
raise
if self._api_type == "responses":
raise
@@ -1827,6 +1852,7 @@ class OpenAICompatProvider(LLMProvider):
_timed_stream(),
on_content_delta,
on_tool_call_delta=on_tool_call_delta,
on_reasoning_delta=on_thinking_delta,
capture=capture,
)
self._record_responses_success(model, reasoning_effort)
@@ -1847,10 +1873,8 @@ class OpenAICompatProvider(LLMProvider):
)
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;
# falling back to /chat/completions cannot succeed and would
# hide the real error.
capabilities = self._responses_capabilities()
if capabilities is not None and not capabilities.allows_chat_fallback:
raise
if self._api_type == "responses":
raise
@@ -12,7 +12,11 @@ def _as_json_object(value: object) -> dict[str, Any] | None:
return cast(dict[str, Any], value) if isinstance(value, dict) else None
def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
def convert_messages(
messages: list[dict[str, Any]],
*,
preserve_reasoning: bool = False,
) -> tuple[str, list[dict[str, Any]]]:
"""Convert Chat Completions messages to Responses API input items.
Returns ``(system_prompt, input_items)`` where *system_prompt* is extracted
@@ -36,6 +40,13 @@ def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
continue
if role == "assistant":
if preserve_reasoning:
reasoning = msg.get("reasoning_content")
if isinstance(reasoning, str) and reasoning:
input_items.append({
"type": "reasoning",
"content": reasoning,
})
if isinstance(content, str) and content:
message_id = _unique_item_id(f"msg_{idx}", used_item_ids)
input_items.append({
+35 -13
View File
@@ -69,7 +69,9 @@ def _response_object(value: object) -> dict[str, Any] | None:
return object_value
dump = getattr(value, "model_dump", None)
if callable(dump):
return _as_json_object(dump())
dumped = _as_json_object(dump())
if dumped is not None:
return dumped
try:
return _as_json_object(vars(value))
except TypeError:
@@ -444,6 +446,14 @@ def _extract_reasoning_summary_from_output(output: object) -> str | None:
for item in _response_object_list(output):
if item.get("type") != "reasoning":
continue
content = item.get("content")
if isinstance(content, str) and content:
parts.append(content)
elif isinstance(content, list):
for block in _response_object_list(cast(list[object], content)):
text = block.get("text")
if isinstance(text, str) and text:
parts.append(text)
for summary in _response_object_list(item.get("summary")):
if summary.get("type") == "summary_text" and summary.get("text"):
text = summary.get("text")
@@ -483,11 +493,9 @@ def parse_response_output(
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"):
text = s.get("text")
if isinstance(text, str):
reasoning_content = (reasoning_content or "") + text
text = _extract_reasoning_summary_from_output([item])
if text:
reasoning_content = (reasoning_content or "") + text
elif item_type == "function_call":
call_id = item.get("call_id") or ""
item_id = item.get("id") or "fc_0"
@@ -532,6 +540,7 @@ 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,
on_reasoning_delta: Callable[[str], 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)``."""
@@ -542,6 +551,7 @@ async def consume_sdk_stream(
finish_reason = "stop"
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 = ""
@@ -572,6 +582,19 @@ 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.reasoning_text.delta":
delta_text = getattr(event, "delta", "") or ""
if delta_text:
reasoning_content = (reasoning_content or "") + delta_text
streamed_reasoning = True
if on_reasoning_delta:
await on_reasoning_delta(delta_text)
elif event_type == "response.reasoning_text.done":
text = getattr(event, "text", "") or ""
if text and not streamed_reasoning and not reasoning_content:
reasoning_content = text
if on_reasoning_delta:
await on_reasoning_delta(text)
elif event_type == "response.refusal.delta":
refusal_seen = True
delta_text = getattr(event, "delta", None)
@@ -689,13 +712,12 @@ async def consume_sdk_stream(
"completion_tokens": int(getattr(usage_obj, "output_tokens", 0) or 0),
"total_tokens": int(getattr(usage_obj, "total_tokens", 0) or 0),
}
for out_item in cast(list[Any], getattr(resp, "output", None) or []):
if getattr(out_item, "type", None) == "reasoning":
for s in cast(list[Any], getattr(out_item, "summary", None) or []):
if getattr(s, "type", None) == "summary_text":
text = getattr(s, "text", None)
if text:
reasoning_content = (reasoning_content or "") + text
if not reasoning_content:
reasoning_content = _extract_reasoning_summary_from_output(
getattr(resp, "output", None)
)
if reasoning_content and on_reasoning_delta:
await on_reasoning_delta(reasoning_content)
elif event_type in {"error", "response.failed"}:
detail = getattr(event, "error", None) or getattr(event, "message", None) or event
raise RuntimeError(f"Response failed: {str(detail)[:500]}")
+9 -2
View File
@@ -43,6 +43,7 @@ def prepare_responses_input(
state: ProviderConversationState | None,
provider: str,
model: str,
preserve_reasoning: bool = False,
) -> tuple[str, list[dict[str, Any]], bool]:
"""Build a request from exact prior items plus only newly appended messages.
@@ -50,7 +51,10 @@ def prepare_responses_input(
When no compatible state exists, it is converted normally as a safe
fallback.
"""
instructions, fallback_items = convert_messages(messages)
instructions, fallback_items = convert_messages(
messages,
preserve_reasoning=preserve_reasoning,
)
if state is None or not responses_state_matches(
state,
provider=provider,
@@ -62,7 +66,10 @@ def prepare_responses_input(
if prior_items is None:
return instructions, fallback_items, False
_, delta_items = convert_messages(state.pending_messages)
_, delta_items = convert_messages(
state.pending_messages,
preserve_reasoning=preserve_reasoning,
)
logger.debug(
"Replaying Responses state: prior_items={} pending_messages={}",
len(prior_items),
+46 -1
View File
@@ -13,7 +13,7 @@ Every entry writes out all fields so you can copy-paste as a template.
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from typing import Any, Literal
from pydantic.alias_generators import to_snake
@@ -28,6 +28,32 @@ class ProviderModelSpec:
context_window: int | None = None
@dataclass(frozen=True)
class ResponsesCapabilities:
"""Provider capabilities for the shared OpenAI Responses execution path.
``reasoning_replay`` selects whether multi-turn reasoning is retained as
encrypted server content, plaintext local history, or not requested.
"""
models: tuple[str, ...] = ()
auto_route: bool = False
requires_direct_openai_base: bool = False
allows_api_type_override: bool = False
reasoning_replay: Literal["none", "encrypted", "plaintext"] = "none"
supports_native_compaction: bool = False
allows_chat_fallback: bool = True
def matches_model(self, model: str) -> bool:
"""Return whether *model* is explicitly routed through Responses."""
model_name = model.lower()
return any(
model_name == supported.lower()
or model_name.endswith(f"/{supported.lower()}")
for supported in self.models
)
@dataclass(frozen=True)
class ProviderSpec:
"""One LLM provider's metadata. See PROVIDERS below for real examples.
@@ -111,6 +137,9 @@ class ProviderSpec:
# Substring match against the wire model name (lowercased).
implicit_reasoning_models: tuple[str, ...] = ()
# Capabilities for providers/models served through the shared Responses path.
responses: ResponsesCapabilities | None = None
# When the model returns content as a list of {"type":"thinking",...} +
# {"type":"text",...} blocks, extract the thinking text into
# reasoning_content. Mistral's Magistral / reasoning-enabled responses use
@@ -368,6 +397,13 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
display_name="OpenAI",
backend="openai_compat",
supports_max_completion_tokens=True,
responses=ResponsesCapabilities(
auto_route=True,
requires_direct_openai_base=True,
allows_api_type_override=True,
reasoning_replay="encrypted",
supports_native_compaction=True,
),
),
# OpenAI Codex: OAuth-based, dedicated provider
ProviderSpec(
@@ -451,6 +487,11 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
strip_model_prefix=True,
is_oauth=True,
supports_max_completion_tokens=True,
responses=ResponsesCapabilities(
auto_route=True,
reasoning_replay="encrypted",
allows_chat_fallback=False,
),
),
# DeepSeek: OpenAI-compatible at api.deepseek.com
ProviderSpec(
@@ -461,6 +502,10 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
backend="openai_compat",
default_api_base="https://api.deepseek.com",
thinking_style="thinking_type",
responses=ResponsesCapabilities(
models=("deepseek-v4-flash",),
reasoning_replay="plaintext",
),
),
# Gemini: Google's OpenAI-compatible endpoint
ProviderSpec(
+1 -1
View File
@@ -51,7 +51,7 @@ dependencies = [
"filelock>=3.25.2",
"watchfiles>=1.1.1,<2.0.0",
"packaging>=24.0",
"tzdata>=2025.2; sys_platform == 'win32'",
"tzdata>=2025.2",
"defusedxml>=0.7.1,<1.0.0",
"pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.0.0",
+1 -1
View File
@@ -2558,7 +2558,7 @@ def test_optional_dependency_metadata_for_enable():
):
assert not any(dep.startswith(dep_name) for dep in required)
for dependency in (
"tzdata>=2025.2; sys_platform == 'win32'",
"tzdata>=2025.2",
"defusedxml>=0.7.1,<1.0.0",
"pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.0.0",
+30
View File
@@ -1,4 +1,8 @@
import json
import os
import subprocess
import sys
import textwrap
import warnings
import pytest
@@ -42,6 +46,32 @@ def test_agent_timezone_rejects_unknown_iana_name() -> None:
Config.model_validate({"agents": {"defaults": {"timezone": "Not/AZone"}}})
def test_agent_timezones_use_packaged_data_without_system_database() -> None:
script = textwrap.dedent(
"""\
from zoneinfo import TZPATH
from nanobot.config.schema import Config
assert not TZPATH
for name in ("UTC", "Asia/Shanghai"):
config = Config.model_validate({"agents": {"defaults": {"timezone": name}}})
serialized = config.model_dump(mode="json", by_alias=True)
restored = Config.model_validate(serialized)
assert restored.agents.defaults.timezone == name
"""
)
result = subprocess.run(
[sys.executable, "-c", script],
env=os.environ | {"PYTHONTZPATH": ""},
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
def test_provider_api_type_accepts_exact_values_only() -> None:
config = Config.model_validate({
"providers": {
@@ -48,6 +48,7 @@ def test_build_responses_body_strips_github_copilot_prefix():
provider_context=ProviderCallContext(context_window_tokens=128_000),
)
assert body["model"] == "gpt-5.4-mini"
assert body["include"] == ["reasoning.encrypted_content"]
assert "context_management" not in body
+56
View File
@@ -150,6 +150,22 @@ class TestConvertMessages:
assert items[0]["content"][0]["type"] == "output_text"
assert items[0]["content"][0]["text"] == "I'll help"
def test_preserves_deepseek_reasoning_content(self):
_, items = convert_messages([
{"role": "assistant", "reasoning_content": "think first", "content": "answer"},
], preserve_reasoning=True)
assert items == [
{"type": "reasoning", "content": "think first"},
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "answer"}],
"status": "completed",
"id": "msg_0",
},
]
def test_assistant_empty_content_skipped(self):
_, items = convert_messages([{"role": "assistant", "content": ""}])
assert len(items) == 0
@@ -539,6 +555,22 @@ class TestParseResponseOutput:
assert result.content == "42"
assert result.reasoning_content == "I think therefore I am."
def test_deepseek_reasoning_content_extracted(self):
resp = {
"output": [
{"type": "reasoning", "content": "think first"},
{"type": "message", "content": [
{"type": "output_text", "text": "answer"},
]},
],
"status": "completed", "usage": {},
}
result = parse_response_output(resp)
assert result.content == "answer"
assert result.reasoning_content == "think first"
def test_empty_output(self):
resp = {"output": [], "status": "completed", "usage": {}}
result = parse_response_output(resp)
@@ -1633,6 +1665,30 @@ class TestConsumeSdkStream:
_, _, _, _, reasoning = await consume_sdk_stream(stream())
assert reasoning == "thinking..."
@pytest.mark.asyncio
async def test_deepseek_reasoning_text_streamed(self):
events = [
MagicMock(type="response.reasoning_text.delta", delta="step 1 "),
MagicMock(type="response.reasoning_text.delta", delta="step 2"),
MagicMock(type="response.reasoning_text.done", text="step 1 step 2"),
]
emitted: list[str] = []
async def stream():
for event in events:
yield event
async def on_reasoning_delta(delta: str) -> None:
emitted.append(delta)
_, _, _, _, reasoning = await consume_sdk_stream(
stream(),
on_reasoning_delta=on_reasoning_delta,
)
assert reasoning == "step 1 step 2"
assert emitted == ["step 1 ", "step 2"]
@pytest.mark.asyncio
async def test_error_event_raises(self):
ev = MagicMock(type="error", error="rate_limit_exceeded")
@@ -10,6 +10,11 @@ from nanobot.providers.openai_compat_provider import (
_RESPONSES_PROBE_INTERVAL_S,
OpenAICompatProvider,
)
from nanobot.providers.registry import (
ProviderSpec,
ResponsesCapabilities,
find_by_name,
)
@pytest.fixture()
@@ -17,7 +22,7 @@ def provider():
"""A direct-OpenAI provider with Responses API support."""
p = OpenAICompatProvider.__new__(OpenAICompatProvider)
p.default_model = "gpt-5"
p._spec = type("Spec", (), {"name": "openai"})()
p._spec = find_by_name("openai")
p._effective_base = "https://api.openai.com/v1"
p._api_type = "auto"
p._responses_failures = {}
@@ -29,6 +34,58 @@ def test_responses_api_available_by_default(provider):
assert provider._should_use_responses_api("gpt-5", None) is True
def test_deepseek_v4_flash_uses_responses_by_model(provider):
provider._spec = find_by_name("deepseek")
provider._effective_base = "https://api.deepseek.com"
provider.default_model = "deepseek-v4-flash"
assert provider._should_use_responses_api("deepseek-v4-flash", None) is True
assert provider._should_use_responses_api("deepseek-v4-pro", None) is False
def test_deepseek_v4_flash_matches_provider_prefixed_model(provider):
provider._spec = find_by_name("deepseek")
provider._effective_base = "https://api.deepseek.com"
assert provider._should_use_responses_api("deepseek/deepseek-v4-flash", None) is True
def test_responses_behavior_is_declared_by_capabilities(provider):
provider._spec = ProviderSpec(
name="example",
keywords=("example",),
env_key="EXAMPLE_API_KEY",
responses=ResponsesCapabilities(
models=("example-o3",),
reasoning_replay="plaintext",
),
)
provider._effective_base = "https://example.test"
assert provider._should_use_responses_api("example-o3", None) is True
body = provider._build_responses_body(
messages=[
{"role": "user", "content": "question"},
{
"role": "assistant",
"reasoning_content": "think first",
"content": "answer",
},
{"role": "user", "content": "follow-up"},
],
tools=None,
model="example-o3",
max_tokens=100,
temperature=0.1,
reasoning_effort="high",
tool_choice=None,
)
assert {"type": "reasoning", "content": "think first"} in body["input"]
assert "include" not in body
def test_direct_openai_enables_server_compaction(provider):
provider._extra_body = {}
@@ -47,6 +104,7 @@ def test_direct_openai_enables_server_compaction(provider):
"type": "compaction",
"compact_threshold": 70_000,
}]
assert body["include"] == ["reasoning.encrypted_content"]
def test_api_type_chat_completions_disables_responses(provider):
@@ -70,7 +128,7 @@ def test_api_type_responses_ignores_circuit_breaker(provider):
def test_api_type_responses_does_not_force_non_openai(provider):
provider._spec = type("Spec", (), {"name": "custom"})()
provider._spec = find_by_name("custom")
provider._api_type = "responses"
assert provider._should_use_responses_api("gpt-4o", None) is False
+9 -64
View File
@@ -37,12 +37,6 @@ 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,
@@ -231,9 +225,6 @@ 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 {
@@ -250,7 +241,6 @@ 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";
@@ -957,16 +947,8 @@ 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(regularSessions, !loading);
useSidebarState(sessions, !loading);
const initialRouteRef = useRef<ShellRoute | null>(null);
if (!initialRouteRef.current) initialRouteRef.current = readShellRoute();
const [activeKey, setActiveKey] = useState<string | null>(
@@ -1132,10 +1114,8 @@ 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, quickSession]);
const quickChatActive = isQuickChatKey(activeKey);
}, [sessions, activeKey]);
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
const activeChatId = activeSession?.chatId ?? null;
@@ -1150,9 +1130,6 @@ function Shell({
});
}, [activeChatId]);
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
if (quickChatActive) {
return workspaces?.default_scope ?? null;
}
if (activeChatId && workspaceOverrides[activeChatId]) {
return workspaceOverrides[activeChatId];
}
@@ -1164,7 +1141,6 @@ function Shell({
activeChatId,
activeSession?.workspaceScope,
draftWorkspaceScope,
quickChatActive,
workspaceOverrides,
workspaces?.default_scope,
]);
@@ -1185,10 +1161,7 @@ function Shell({
useEffect(() => {
if (loading) return;
const knownChatIds = new Set([
QUICK_CHAT_ID,
...sessions.map((session) => session.chatId),
]);
const knownChatIds = new Set(sessions.map((session) => session.chatId));
setUpdatedChatIds((current) => {
const next = new Set(
Array.from(current).filter((chatId) => knownChatIds.has(chatId)),
@@ -1203,7 +1176,6 @@ function Shell({
useEffect(() => {
if (loading || !activeKey) return;
if (isQuickChatKey(activeKey)) return;
if (sessions.some((session) => session.key === activeKey)) return;
const currentRoute = readShellRoute();
navigate(
@@ -1445,18 +1417,6 @@ 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;
@@ -1722,7 +1682,6 @@ 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;
})();
@@ -1814,10 +1773,7 @@ function Shell({
});
}, [client, t]);
const onTurnEnd = useDeferredTitleRefresh(
quickChatActive ? null : activeSession,
refresh,
);
const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh);
const onConfirmDelete = useCallback(async () => {
if (!pendingDelete) return;
@@ -1907,9 +1863,7 @@ function Shell({
});
}, []);
const headerTitle = quickChatActive
? t("sidebar.quickChat")
: activeSession
const headerTitle = activeSession
? sidebarState.title_overrides[activeSession.key] ||
activeSession.title ||
deriveTitle(activeSession.preview, t("chat.newChat"))
@@ -1946,11 +1900,9 @@ function Shell({
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
const sidebarProps = {
sessions: regularSessions,
sessions,
activeKey,
loading,
quickChatActive,
onOpenQuickChat,
onNewChat,
onSelect: onSelectChat,
onRequestDelete,
@@ -2113,7 +2065,7 @@ function Shell({
<SessionSearchDialog
open
onOpenChange={setSessionSearchOpen}
sessions={regularSessions}
sessions={sessions}
activeKey={activeKey}
loading={loading}
titleOverrides={sidebarState.title_overrides}
@@ -2138,7 +2090,7 @@ function Shell({
onToggleSidebar={toggleSidebar}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
onForkChat={quickChatActive ? undefined : onForkChat}
onForkChat={onForkChat}
onTurnEnd={onTurnEnd}
theme={theme}
onToggleTheme={toggle}
@@ -2147,20 +2099,13 @@ function Shell({
hideHeader={false}
workspaceScope={activeWorkspaceScope}
workspaceDefaultScope={workspaces?.default_scope ?? null}
workspaceControls={
quickChatActive ? null : (workspaces?.controls ?? null)
}
workspaceControls={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" && (
-10
View File
@@ -3,7 +3,6 @@ import {
Archive,
Brain,
CalendarClock,
MessageCircle,
Menu,
Search,
Settings,
@@ -25,8 +24,6 @@ interface SidebarProps {
sessions: ChatSummary[];
activeKey: string | null;
loading: boolean;
quickChatActive: boolean;
onOpenQuickChat: () => void;
onNewChat: () => void;
onSelect: (key: string) => void;
onRequestDelete: (key: string, label: string) => void;
@@ -142,13 +139,6 @@ export function Sidebar(props: SidebarProps) {
collapsed && "flex w-14 flex-col items-center px-0",
)}
>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.quickChat")}
onClick={props.onOpenQuickChat}
active={props.quickChatActive}
icon={<MessageCircle className="h-4 w-4" />}
/>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.newChat")}
+5 -17
View File
@@ -315,9 +315,6 @@ interface ThreadShellProps {
settingsSnapshot?: SettingsPayload | null;
onOpenModelSettings?: () => void;
skills?: SkillSummary[];
allowConversationReset?: boolean;
showSessionInfo?: boolean;
emptyStateGreeting?: string;
}
function toModelBadgeLabel(modelName: string | null): string | null {
@@ -600,9 +597,6 @@ export function ThreadShell({
settingsSnapshot = null,
onOpenModelSettings,
skills = [],
allowConversationReset = true,
showSessionInfo = true,
emptyStateGreeting,
}: ThreadShellProps) {
const { t } = useTranslation();
const chatId = session?.chatId ?? null;
@@ -628,12 +622,6 @@ 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,
@@ -1386,7 +1374,7 @@ export function ThreadShell({
fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant={showHeroComposer ? "hero" : "thread"}
slashCommands={availableSlashCommands}
slashCommands={slashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
skills={skills}
@@ -1428,7 +1416,7 @@ export function ThreadShell({
fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant="hero"
slashCommands={availableSlashCommands}
slashCommands={slashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
skills={skills}
@@ -1454,10 +1442,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={emptyStateGreeting ?? t(heroGreetingKey)} />
<HeroGreeting text={t(heroGreetingKey)} />
</div>
);
const sessionInfoAction = historyKey && showSessionInfo ? (
const sessionInfoAction = historyKey ? (
<SessionInfoPopover sessionKey={historyKey} token={token} title={title} />
) : undefined;
const promptNavigatorAction = historyKey ? (
@@ -1500,7 +1488,7 @@ export function ThreadShell({
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
slashCommands={availableSlashCommands}
slashCommands={slashCommands}
forkBoundaryMessageCount={forkBoundaryMessageCount}
hasMoreBefore={hasMoreBefore}
loadingOlder={loadingOlder}
@@ -542,7 +542,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
const near = distance < NEAR_BOTTOM_PX;
const owner = threadMotionRef.current?.observeScroll(near) ?? "automatic";
const logicallyAtBottom = owner === "automatic" || near;
const logicallyAtBottom = owner === "automatic" || (owner === "navigation" && near);
setAtBottom((current) =>
current === logicallyAtBottom ? current : logicallyAtBottom,
);
@@ -557,6 +557,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
if (!direction) return;
threadMotionRef.current?.handleUserScrollIntent(
canScrollInDirection(el, direction),
direction === "forward",
);
};
const handleWheel = (event: WheelEvent) => {
@@ -572,20 +573,21 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const handlePointerDown = (event: PointerEvent) => {
if (event.button === 0 && event.target === el) yieldCameraToUser();
};
let touchStartY: number | null = null;
let lastTouchY: number | null = null;
const handleTouchStart = (event: TouchEvent) => {
touchStartY = event.touches[0]?.clientY ?? null;
lastTouchY = event.touches[0]?.clientY ?? null;
};
const handleTouchMove = (event: TouchEvent) => {
const currentY = event.touches[0]?.clientY;
const scrollDeltaY =
touchStartY !== null && currentY !== undefined
? touchStartY - currentY
lastTouchY !== null && currentY !== undefined
? lastTouchY - currentY
: 0;
lastTouchY = currentY ?? null;
handleDirectionalInput(directionFromDelta(scrollDeltaY));
};
const handleTouchEnd = () => {
touchStartY = null;
lastTouchY = null;
};
const handleKeyDown = (event: KeyboardEvent) => {
if (
+34 -5
View File
@@ -168,6 +168,9 @@ export class ThreadMotionCoordinator {
private measurementFrameId: number | null = null;
private geometryDirty = false;
private composerInputDuringTurn = false;
// A user leaving the live tail must first move beyond the near-bottom
// boundary, or explicitly reverse toward latest, before follow can resume.
private resumeFollowArmed = false;
constructor(options: ThreadMotionCoordinatorOptions) {
this.camera = options.camera;
@@ -198,6 +201,7 @@ export class ThreadMotionCoordinator {
if (isNewTurn) {
this.camera.cancel();
this.composerInputDuringTurn = false;
this.resumeFollowArmed = false;
this.promptPositioned = turn.entry === "restored";
this.mode = this.promptPositioned && turn.hasOutput
? "follow-output"
@@ -249,15 +253,31 @@ export class ThreadMotionCoordinator {
this.handleUserScrollIntent(true);
}
handleUserScrollIntent(canScroll: boolean): void {
handleUserScrollIntent(canScroll: boolean, towardLatest = false): void {
if (this.mode === "browsing-history" && towardLatest && !canScroll) {
this.transitionToAutoFollow(false);
return;
}
const event = canScroll ? "user-scroll" : "boundary-scroll";
if (!this.transition(event)) return;
const transitioned = this.transition(event);
if (this.mode === "browsing-history" && canScroll) {
this.resumeFollowArmed = towardLatest;
} else if (transitioned && this.mode === "browsing-history") {
this.resumeFollowArmed = false;
}
if (!transitioned) return;
this.camera.cancel();
}
resumeAutoFollow(): void {
this.transitionToAutoFollow(true);
}
private transitionToAutoFollow(cancelCamera: boolean): void {
if (!this.transition("resume-follow")) return;
this.camera.cancel();
this.resumeFollowArmed = false;
if (cancelCamera) this.camera.cancel();
this.onAutoFollow?.();
this.invalidateGeometry();
}
@@ -317,11 +337,19 @@ export class ThreadMotionCoordinator {
case "navigating-history":
if (!this.camera.isFollowing()) {
this.transition("navigation-settled");
if (nearBottom) this.resumeAutoFollow();
if (nearBottom) {
this.resumeAutoFollow();
} else {
this.resumeFollowArmed = true;
}
}
return "navigation";
case "browsing-history":
if (!nearBottom) return "user";
if (!nearBottom) {
this.resumeFollowArmed = true;
return "user";
}
if (!this.resumeFollowArmed) return "user";
this.resumeAutoFollow();
return "automatic";
default:
@@ -339,6 +367,7 @@ export class ThreadMotionCoordinator {
this.camera.cancel();
this.turn = { id: null, promptId: null, hasOutput: false };
this.composerInputDuringTurn = false;
this.resumeFollowArmed = false;
this.mode = "idle";
this.promptPositioned = false;
}
-4
View File
@@ -43,7 +43,6 @@
"sidebar": {
"navigation": "Sidebar navigation",
"collapse": "Collapse sidebar",
"quickChat": "Quick Chat",
"newChat": "New topic",
"searchAria": "Search",
"searchPlaceholder": "Search",
@@ -61,9 +60,6 @@
"title": "Skills"
}
},
"quickChat": {
"greeting": "What's on your mind?"
},
"settings": {
"backToChat": "Back to chat",
"sidebar": {
-4
View File
@@ -43,7 +43,6 @@
"sidebar": {
"navigation": "Navegación de la barra lateral",
"collapse": "Contraer barra lateral",
"quickChat": "Chat rápido",
"newChat": "Nuevo tema",
"searchAria": "Buscar",
"searchPlaceholder": "Buscar",
@@ -61,9 +60,6 @@
"title": "Habilidades"
}
},
"quickChat": {
"greeting": "¿Qué tienes en mente?"
},
"settings": {
"backToChat": "Volver al chat",
"sidebar": {
-4
View File
@@ -43,7 +43,6 @@
"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",
@@ -61,9 +60,6 @@
"title": "Compétences"
}
},
"quickChat": {
"greeting": "De quoi avez-vous envie de parler ?"
},
"settings": {
"backToChat": "Retour au chat",
"sidebar": {
-4
View File
@@ -43,7 +43,6 @@
"sidebar": {
"navigation": "Navigasi bilah samping",
"collapse": "Ciutkan sidebar",
"quickChat": "Obrolan cepat",
"newChat": "Topik baru",
"searchAria": "Cari",
"searchPlaceholder": "Cari",
@@ -61,9 +60,6 @@
"title": "Skill"
}
},
"quickChat": {
"greeting": "Apa yang sedang kamu pikirkan?"
},
"settings": {
"backToChat": "Kembali ke chat",
"sidebar": {
-4
View File
@@ -43,7 +43,6 @@
"sidebar": {
"navigation": "サイドバーのナビゲーション",
"collapse": "サイドバーを閉じる",
"quickChat": "クイックチャット",
"newChat": "新しいトピック",
"searchAria": "検索",
"searchPlaceholder": "検索",
@@ -61,9 +60,6 @@
"title": "スキル"
}
},
"quickChat": {
"greeting": "何について話しますか?"
},
"settings": {
"backToChat": "チャットに戻る",
"sidebar": {
-4
View File
@@ -43,7 +43,6 @@
"sidebar": {
"navigation": "사이드바 탐색",
"collapse": "사이드바 접기",
"quickChat": "빠른 채팅",
"newChat": "새 주제",
"searchAria": "검색",
"searchPlaceholder": "검색",
@@ -61,9 +60,6 @@
"title": "스킬"
}
},
"quickChat": {
"greeting": "무슨 이야기를 나눠볼까요?"
},
"settings": {
"backToChat": "채팅으로 돌아가기",
"sidebar": {
-4
View File
@@ -43,7 +43,6 @@
"sidebar": {
"navigation": "Navegação da barra lateral",
"collapse": "Recolher barra lateral",
"quickChat": "Chat rápido",
"newChat": "Novo tópico",
"searchAria": "Buscar",
"searchPlaceholder": "Buscar",
@@ -61,9 +60,6 @@
"title": "Skills"
}
},
"quickChat": {
"greeting": "O que você está pensando?"
},
"settings": {
"backToChat": "Voltar para a conversa",
"sidebar": {
-4
View File
@@ -43,7 +43,6 @@
"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",
@@ -61,9 +60,6 @@
"title": "Kỹ năng"
}
},
"quickChat": {
"greeting": "Bạn đang nghĩ gì?"
},
"settings": {
"backToChat": "Quay lại chat",
"sidebar": {
-4
View File
@@ -43,7 +43,6 @@
"sidebar": {
"navigation": "侧边栏导航",
"collapse": "收起侧边栏",
"quickChat": "随便聊聊",
"newChat": "新建话题",
"searchAria": "搜索",
"searchPlaceholder": "搜索",
@@ -61,9 +60,6 @@
"title": "技能"
}
},
"quickChat": {
"greeting": "想聊点什么?"
},
"settings": {
"backToChat": "返回聊天",
"sidebar": {
-4
View File
@@ -43,7 +43,6 @@
"sidebar": {
"navigation": "側邊欄導覽",
"collapse": "收合側邊欄",
"quickChat": "輕鬆聊聊",
"newChat": "新增話題",
"searchAria": "搜尋",
"searchPlaceholder": "搜尋",
@@ -61,9 +60,6 @@
"title": "技能"
}
},
"quickChat": {
"greeting": "想聊點什麼?"
},
"settings": {
"backToChat": "返回聊天",
"sidebar": {
-22
View File
@@ -1,22 +0,0 @@
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,
};
}
-80
View File
@@ -349,86 +349,6 @@ 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",
});
fireEvent.click(quickChatButton);
expect(window.location.hash).toBe("#/quick-chat");
expect(quickChatButton).toHaveAttribute("aria-current", "page");
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();
});
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");
-37
View File
@@ -1,37 +0,0 @@
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",
});
});
});
+54
View File
@@ -410,6 +410,9 @@ describe("ThreadMotionCoordinator", () => {
expect(camera.jumpTo).toHaveBeenCalledWith(780);
coordinator.takeUserControl();
expect(coordinator.observeScroll(true)).toBe("user");
expect(coordinator.snapshot().mode).toBe("browsing-history");
expect(coordinator.observeScroll(false)).toBe("user");
expect(coordinator.snapshot().mode).toBe("browsing-history");
@@ -417,6 +420,57 @@ describe("ThreadMotionCoordinator", () => {
expect(coordinator.snapshot().mode).toBe("anchor-prompt");
});
it("resumes shallow history browsing when user intent turns toward latest", () => {
const {
camera,
coordinator,
advanceFrame,
} = motionHarness({
scrollTop: 1_400,
});
coordinator.updateTurn({
id: "turn-1",
promptId: "prompt-1",
hasOutput: true,
});
advanceFrame();
camera.followTo.mockClear();
coordinator.handleUserScrollIntent(true);
expect(coordinator.observeScroll(true)).toBe("user");
advanceFrame();
expect(camera.followTo).not.toHaveBeenCalled();
coordinator.handleUserScrollIntent(true, true);
expect(coordinator.observeScroll(true)).toBe("automatic");
expect(coordinator.snapshot().mode).toBe("follow-output");
advanceFrame();
expect(camera.followTo).toHaveBeenCalledWith(1_400);
});
it("resumes shallow history browsing from forward intent at the boundary", () => {
const {
advanceFrame,
coordinator,
onAutoFollow,
} = motionHarness({
scrollTop: 1_400,
});
coordinator.updateTurn({
id: "turn-1",
promptId: "prompt-1",
hasOutput: true,
});
advanceFrame();
coordinator.handleUserScrollIntent(true);
expect(coordinator.observeScroll(true)).toBe("user");
coordinator.handleUserScrollIntent(false, true);
expect(coordinator.snapshot().mode).toBe("follow-output");
expect(onAutoFollow).toHaveBeenCalledTimes(1);
});
it("preserves history browsing when an active turn is cleared", () => {
const {
camera,
-68
View File
@@ -3369,74 +3369,6 @@ 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");
+95
View File
@@ -763,6 +763,101 @@ describe("ThreadViewport", () => {
}
});
it("keeps shallow wheel and touch scrolling user-owned until intent reverses", async () => {
const followTo = vi.spyOn(ThreadCameraController.prototype, "followTo");
const threaded: UIMessage[] = [
{ id: "u1", role: "user", content: "old question", turnId: "turn-1", createdAt: 1 },
{ id: "a1", role: "assistant", content: "old answer", turnId: "turn-1", createdAt: 2 },
{ id: "u2", role: "user", content: "new question", turnId: "turn-2", createdAt: 3 },
];
const answer: UIMessage = {
id: "a2",
role: "assistant",
content: "streaming answer",
turnId: "turn-2",
isStreaming: true,
createdAt: 4,
};
const { container, rerender } = render(
<ThreadViewport
messages={threaded}
isStreaming
composer={<div>composer</div>}
/>,
);
const scroller = getScroller(container);
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1_904 },
clientHeight: { configurable: true, value: 500 },
scrollTop: { configurable: true, writable: true, value: 1_404 },
});
const prompt = container.querySelector<HTMLElement>('[data-user-prompt-id="u2"]');
expect(prompt).not.toBeNull();
Object.defineProperty(prompt, "offsetTop", {
configurable: true,
value: 1_420,
});
rerender(
<ThreadViewport
messages={[...threaded, answer]}
isStreaming
composer={<div>composer</div>}
activeTurnId="turn-2"
activeTurnStartedHere
/>,
);
await flushAnimationFrame();
followTo.mockClear();
act(() => {
fireEvent.wheel(scroller, { deltaY: -24 });
scroller.scrollTop = 1_380;
scroller.dispatchEvent(new Event("scroll"));
});
await flushAnimationFrame();
expect(followTo).not.toHaveBeenCalled();
expect(scroller.scrollTop).toBe(1_380);
expect(screen.getByRole("button", { name: "Scroll to bottom" })).toBeInTheDocument();
act(() => {
scroller.scrollTop = 1_404;
scroller.dispatchEvent(new Event("scroll"));
fireEvent.wheel(scroller, { deltaY: 24 });
});
await flushAnimationFrame();
expect(followTo).toHaveBeenCalledWith(1_404);
expect(scroller.scrollTop).toBe(1_404);
expect(screen.queryByRole("button", { name: "Scroll to bottom" }))
.not.toBeInTheDocument();
followTo.mockClear();
act(() => {
fireEvent.touchStart(scroller, { touches: [{ clientY: 300 }] });
fireEvent.touchMove(scroller, { touches: [{ clientY: 324 }] });
scroller.scrollTop = 1_380;
scroller.dispatchEvent(new Event("scroll"));
});
await flushAnimationFrame();
expect(followTo).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Scroll to bottom" })).toBeInTheDocument();
act(() => {
fireEvent.touchMove(scroller, { touches: [{ clientY: 300 }] });
scroller.scrollTop = 1_404;
scroller.dispatchEvent(new Event("scroll"));
fireEvent.touchEnd(scroller);
});
await flushAnimationFrame();
expect(followTo).toHaveBeenCalledWith(1_404);
expect(screen.queryByRole("button", { name: "Scroll to bottom" }))
.not.toBeInTheDocument();
});
it("keeps the scroll-to-bottom button above a growing composer", async () => {
const resizeObserver = stubResizeObserver();