mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 06:14:02 +00:00
Merge branch 'main' into nanobot-webui
Made-with: Cursor
This commit is contained in:
commit
1b211c7d3a
135
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
135
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: Report a bug or unexpected behavior
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for reporting a bug! Please fill out the sections below to help us diagnose the issue.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Bug Description
|
||||||
|
description: A clear description of what went wrong.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce
|
||||||
|
description: How can we reproduce this behavior?
|
||||||
|
placeholder: |
|
||||||
|
1. Configure nanobot with ...
|
||||||
|
2. Send message ...
|
||||||
|
3. See error ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected Behavior
|
||||||
|
description: What did you expect to happen?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Relevant Logs
|
||||||
|
description: |
|
||||||
|
Paste any relevant log output. You can run nanobot with `--log-level DEBUG` for more verbose logs.
|
||||||
|
**Remember to redact any sensitive information (tokens, API keys, passwords, etc.)**
|
||||||
|
render: shell
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: nanobot Version
|
||||||
|
description: Run `nanobot --version` or `pip show nanobot-ai`
|
||||||
|
placeholder: e.g., 0.1.5
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: python_version
|
||||||
|
attributes:
|
||||||
|
label: Python Version
|
||||||
|
description: What Python version are you using?
|
||||||
|
options:
|
||||||
|
- "3.11"
|
||||||
|
- "3.12"
|
||||||
|
- "3.13"
|
||||||
|
- Other (specify below)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: os
|
||||||
|
attributes:
|
||||||
|
label: Operating System
|
||||||
|
options:
|
||||||
|
- Windows
|
||||||
|
- macOS
|
||||||
|
- Linux
|
||||||
|
- Docker
|
||||||
|
- Other (specify below)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: channel
|
||||||
|
attributes:
|
||||||
|
label: Channel / Platform
|
||||||
|
description: Which messaging platform are you using?
|
||||||
|
options:
|
||||||
|
- Weixin (Personal WeChat)
|
||||||
|
- WeCom (Enterprise WeChat)
|
||||||
|
- Feishu (Lark)
|
||||||
|
- DingTalk
|
||||||
|
- Telegram
|
||||||
|
- Discord
|
||||||
|
- Slack
|
||||||
|
- QQ
|
||||||
|
- WhatsApp
|
||||||
|
- Email
|
||||||
|
- MS Teams
|
||||||
|
- Matrix
|
||||||
|
- WebSocket
|
||||||
|
- API Server
|
||||||
|
- Other (specify below)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: llm_provider
|
||||||
|
attributes:
|
||||||
|
label: LLM Provider
|
||||||
|
description: Which LLM provider are you using?
|
||||||
|
options:
|
||||||
|
- OpenAI
|
||||||
|
- Anthropic (Claude)
|
||||||
|
- DeepSeek
|
||||||
|
- Google (Gemini)
|
||||||
|
- Ollama (Local)
|
||||||
|
- OpenRouter
|
||||||
|
- Azure OpenAI
|
||||||
|
- Other (specify below)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: config
|
||||||
|
attributes:
|
||||||
|
label: Configuration (Optional)
|
||||||
|
description: |
|
||||||
|
Relevant parts of your nanobot configuration. **Remember to redact any sensitive information.**
|
||||||
|
render: yaml
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Any other context, screenshots, or information that might help.
|
||||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Question / Support
|
||||||
|
url: https://github.com/HKUDS/nanobot/discussions
|
||||||
|
about: Ask questions and get help from the community in Discussions.
|
||||||
55
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
55
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Suggest a new feature or enhancement
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for suggesting a feature! Please describe your idea clearly.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem / Motivation
|
||||||
|
description: What problem does this feature solve? What are you trying to accomplish?
|
||||||
|
placeholder: I'm always frustrated when ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: Proposed Solution
|
||||||
|
description: How would you like this to work?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives Considered
|
||||||
|
description: What other approaches have you considered?
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: component
|
||||||
|
attributes:
|
||||||
|
label: Related Component
|
||||||
|
description: Which part of nanobot does this relate to?
|
||||||
|
options:
|
||||||
|
- Channel (WeChat, Feishu, Telegram, etc.)
|
||||||
|
- LLM Provider
|
||||||
|
- Agent / Prompts
|
||||||
|
- Skills / Plugins
|
||||||
|
- Configuration
|
||||||
|
- CLI
|
||||||
|
- API Server
|
||||||
|
- Documentation
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Any other context, examples from other projects, screenshots, etc.
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -11,10 +11,7 @@ webui/node_modules/
|
|||||||
webui/dist/
|
webui/dist/
|
||||||
webui/coverage/
|
webui/coverage/
|
||||||
webui/.vite/
|
webui/.vite/
|
||||||
webui/*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Built webui assets shipped from `webui/` into the Python package
|
|
||||||
nanobot/web/dist/
|
|
||||||
|
|
||||||
# Python bytecode & caches
|
# Python bytecode & caches
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import json
|
|||||||
import types
|
import types
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Any, NamedTuple, get_args, get_origin
|
from typing import Any, Literal, NamedTuple, get_args, get_origin
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import questionary
|
import questionary
|
||||||
@ -202,6 +202,8 @@ def _get_field_type_info(field_info) -> FieldTypeInfo:
|
|||||||
return FieldTypeInfo(name, None)
|
return FieldTypeInfo(name, None)
|
||||||
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
||||||
return FieldTypeInfo("model", annotation)
|
return FieldTypeInfo("model", annotation)
|
||||||
|
if origin is Literal:
|
||||||
|
return FieldTypeInfo("literal", list(args))
|
||||||
return FieldTypeInfo("str", None)
|
return FieldTypeInfo("str", None)
|
||||||
|
|
||||||
|
|
||||||
@ -264,7 +266,12 @@ def _format_value(value: Any, rich: bool = True, field_name: str = "") -> str:
|
|||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return ", ".join(str(v) for v in value)
|
return ", ".join(str(v) for v in value)
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
return json.dumps(value)
|
# Handle dicts containing BaseModel instances
|
||||||
|
parts = []
|
||||||
|
for k, v in value.items():
|
||||||
|
formatted = _format_value(v, rich=False, field_name=str(k))
|
||||||
|
parts.append(f"{k}: {formatted}")
|
||||||
|
return ", ".join(parts) if parts else ("[dim]not set[/dim]" if rich else "[not set]")
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
@ -279,6 +286,63 @@ def _format_value_for_input(value: Any, field_type: str) -> str:
|
|||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_field_constraint(value: Any, field_info) -> str | None:
|
||||||
|
"""Validate a value against Pydantic Field constraints.
|
||||||
|
|
||||||
|
Returns an error message string if validation fails, None if valid.
|
||||||
|
Uses attribute-based detection to handle Pydantic v2 internal types.
|
||||||
|
"""
|
||||||
|
if field_info is None or not hasattr(field_info, "metadata"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
for m in field_info.metadata:
|
||||||
|
if hasattr(m, "ge") and isinstance(value, (int, float)):
|
||||||
|
if value < m.ge:
|
||||||
|
return f"Value must be >= {m.ge}"
|
||||||
|
if hasattr(m, "gt") and isinstance(value, (int, float)):
|
||||||
|
if value <= m.gt:
|
||||||
|
return f"Value must be > {m.gt}"
|
||||||
|
if hasattr(m, "le") and isinstance(value, (int, float)):
|
||||||
|
if value > m.le:
|
||||||
|
return f"Value must be <= {m.le}"
|
||||||
|
if hasattr(m, "lt") and isinstance(value, (int, float)):
|
||||||
|
if value >= m.lt:
|
||||||
|
return f"Value must be < {m.lt}"
|
||||||
|
if hasattr(m, "min_length") and hasattr(value, "__len__"):
|
||||||
|
if len(value) < m.min_length:
|
||||||
|
return f"Length must be >= {m.min_length}"
|
||||||
|
if hasattr(m, "max_length") and hasattr(value, "__len__"):
|
||||||
|
if len(value) > m.max_length:
|
||||||
|
return f"Length must be <= {m.max_length}"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_constraint_hint(field_info) -> str:
|
||||||
|
"""Derive a human-readable constraint hint from field metadata.
|
||||||
|
|
||||||
|
Returns a string like "(0-10)" or "(>= 0)" to append to field display names.
|
||||||
|
"""
|
||||||
|
if field_info is None or not hasattr(field_info, "metadata"):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
ge_val = None
|
||||||
|
le_val = None
|
||||||
|
for m in field_info.metadata:
|
||||||
|
if hasattr(m, "ge"):
|
||||||
|
ge_val = m.ge
|
||||||
|
if hasattr(m, "le"):
|
||||||
|
le_val = m.le
|
||||||
|
|
||||||
|
if ge_val is not None and le_val is not None:
|
||||||
|
return f" ({ge_val}-{le_val})"
|
||||||
|
if ge_val is not None:
|
||||||
|
return f" (>= {ge_val})"
|
||||||
|
if le_val is not None:
|
||||||
|
return f" (<= {le_val})"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
# --- Rich UI Components ---
|
# --- Rich UI Components ---
|
||||||
|
|
||||||
|
|
||||||
@ -333,7 +397,7 @@ def _input_bool(display_name: str, current: bool | None) -> bool | None:
|
|||||||
).ask()
|
).ask()
|
||||||
|
|
||||||
|
|
||||||
def _input_text(display_name: str, current: Any, field_type: str) -> Any:
|
def _input_text(display_name: str, current: Any, field_type: str, field_info=None) -> Any:
|
||||||
"""Get text input and parse based on field type."""
|
"""Get text input and parse based on field type."""
|
||||||
default = _format_value_for_input(current, field_type)
|
default = _format_value_for_input(current, field_type)
|
||||||
|
|
||||||
@ -344,16 +408,28 @@ def _input_text(display_name: str, current: Any, field_type: str) -> Any:
|
|||||||
|
|
||||||
if field_type == "int":
|
if field_type == "int":
|
||||||
try:
|
try:
|
||||||
return int(value)
|
parsed = int(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
console.print("[yellow]! Invalid number format, value not saved[/yellow]")
|
console.print("[yellow]! Invalid number format, value not saved[/yellow]")
|
||||||
return None
|
return None
|
||||||
|
if field_info:
|
||||||
|
error = _validate_field_constraint(parsed, field_info)
|
||||||
|
if error:
|
||||||
|
console.print(f"[yellow]! {error}, value not saved[/yellow]")
|
||||||
|
return None
|
||||||
|
return parsed
|
||||||
elif field_type == "float":
|
elif field_type == "float":
|
||||||
try:
|
try:
|
||||||
return float(value)
|
parsed = float(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
console.print("[yellow]! Invalid number format, value not saved[/yellow]")
|
console.print("[yellow]! Invalid number format, value not saved[/yellow]")
|
||||||
return None
|
return None
|
||||||
|
if field_info:
|
||||||
|
error = _validate_field_constraint(parsed, field_info)
|
||||||
|
if error:
|
||||||
|
console.print(f"[yellow]! {error}, value not saved[/yellow]")
|
||||||
|
return None
|
||||||
|
return parsed
|
||||||
elif field_type == "list":
|
elif field_type == "list":
|
||||||
return [v.strip() for v in value.split(",") if v.strip()]
|
return [v.strip() for v in value.split(",") if v.strip()]
|
||||||
elif field_type == "dict":
|
elif field_type == "dict":
|
||||||
@ -367,7 +443,7 @@ def _input_text(display_name: str, current: Any, field_type: str) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
def _input_with_existing(
|
def _input_with_existing(
|
||||||
display_name: str, current: Any, field_type: str
|
display_name: str, current: Any, field_type: str, field_info=None
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Handle input with 'keep existing' option for non-empty values."""
|
"""Handle input with 'keep existing' option for non-empty values."""
|
||||||
has_existing = current is not None and current != "" and current != {} and current != []
|
has_existing = current is not None and current != "" and current != {} and current != []
|
||||||
@ -381,7 +457,7 @@ def _input_with_existing(
|
|||||||
if choice == "Keep existing value" or choice is None:
|
if choice == "Keep existing value" or choice is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return _input_text(display_name, current, field_type)
|
return _input_text(display_name, current, field_type, field_info=field_info)
|
||||||
|
|
||||||
|
|
||||||
# --- Pydantic Model Configuration ---
|
# --- Pydantic Model Configuration ---
|
||||||
@ -568,7 +644,7 @@ def _configure_pydantic_model(
|
|||||||
field_name, field_info = fields[field_idx]
|
field_name, field_info = fields[field_idx]
|
||||||
current_value = getattr(working_model, field_name, None)
|
current_value = getattr(working_model, field_name, None)
|
||||||
ftype = _get_field_type_info(field_info)
|
ftype = _get_field_type_info(field_info)
|
||||||
field_display = _get_field_display_name(field_name, field_info)
|
field_display = _get_field_display_name(field_name, field_info) + _get_constraint_hint(field_info)
|
||||||
|
|
||||||
# Nested Pydantic model - recurse
|
# Nested Pydantic model - recurse
|
||||||
if ftype.type_name == "model":
|
if ftype.type_name == "model":
|
||||||
@ -607,10 +683,19 @@ def _configure_pydantic_model(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Generic field input
|
# Generic field input
|
||||||
|
if ftype.type_name == "literal" and ftype.inner_type:
|
||||||
|
select_choices = [str(v) for v in ftype.inner_type]
|
||||||
|
default_choice = str(current_value) if current_value in ftype.inner_type else select_choices[0]
|
||||||
|
new_value = _select_with_back(field_display, select_choices, default=default_choice)
|
||||||
|
if new_value is _BACK_PRESSED:
|
||||||
|
continue
|
||||||
|
if new_value is not None:
|
||||||
|
setattr(working_model, field_name, new_value)
|
||||||
|
continue
|
||||||
if ftype.type_name == "bool":
|
if ftype.type_name == "bool":
|
||||||
new_value = _input_bool(field_display, current_value)
|
new_value = _input_bool(field_display, current_value)
|
||||||
else:
|
else:
|
||||||
new_value = _input_with_existing(field_display, current_value, ftype.type_name)
|
new_value = _input_with_existing(field_display, current_value, ftype.type_name, field_info=field_info)
|
||||||
if new_value is not None:
|
if new_value is not None:
|
||||||
setattr(working_model, field_name, new_value)
|
setattr(working_model, field_name, new_value)
|
||||||
|
|
||||||
@ -821,18 +906,24 @@ def _configure_channels(config: Config) -> None:
|
|||||||
|
|
||||||
_SETTINGS_SECTIONS: dict[str, tuple[str, str, set[str] | None]] = {
|
_SETTINGS_SECTIONS: dict[str, tuple[str, str, set[str] | None]] = {
|
||||||
"Agent Settings": ("Agent Defaults", "Configure default model, temperature, and behavior", None),
|
"Agent Settings": ("Agent Defaults", "Configure default model, temperature, and behavior", None),
|
||||||
|
"Channel Common": ("Channel Common", "Configure cross-channel behavior: progress, tool hints, retries", None),
|
||||||
|
"API Server": ("API Server", "Configure OpenAI-compatible API endpoint", None),
|
||||||
"Gateway": ("Gateway Settings", "Configure server host, port, and heartbeat", None),
|
"Gateway": ("Gateway Settings", "Configure server host, port, and heartbeat", None),
|
||||||
"Tools": ("Tools Settings", "Configure web search, shell exec, and other tools", {"mcp_servers"}),
|
"Tools": ("Tools Settings", "Configure web search, shell exec, and other tools", {"mcp_servers"}),
|
||||||
}
|
}
|
||||||
|
|
||||||
_SETTINGS_GETTER = {
|
_SETTINGS_GETTER = {
|
||||||
"Agent Settings": lambda c: c.agents.defaults,
|
"Agent Settings": lambda c: c.agents.defaults,
|
||||||
|
"Channel Common": lambda c: c.channels,
|
||||||
|
"API Server": lambda c: c.api,
|
||||||
"Gateway": lambda c: c.gateway,
|
"Gateway": lambda c: c.gateway,
|
||||||
"Tools": lambda c: c.tools,
|
"Tools": lambda c: c.tools,
|
||||||
}
|
}
|
||||||
|
|
||||||
_SETTINGS_SETTER = {
|
_SETTINGS_SETTER = {
|
||||||
"Agent Settings": lambda c, v: setattr(c.agents, "defaults", v),
|
"Agent Settings": lambda c, v: setattr(c.agents, "defaults", v),
|
||||||
|
"Channel Common": lambda c, v: setattr(c, "channels", v),
|
||||||
|
"API Server": lambda c, v: setattr(c, "api", v),
|
||||||
"Gateway": lambda c, v: setattr(c, "gateway", v),
|
"Gateway": lambda c, v: setattr(c, "gateway", v),
|
||||||
"Tools": lambda c, v: setattr(c, "tools", v),
|
"Tools": lambda c, v: setattr(c, "tools", v),
|
||||||
}
|
}
|
||||||
@ -915,12 +1006,20 @@ def _show_summary(config: Config) -> None:
|
|||||||
# Settings sections
|
# Settings sections
|
||||||
for title, model in [
|
for title, model in [
|
||||||
("Agent Settings", config.agents.defaults),
|
("Agent Settings", config.agents.defaults),
|
||||||
|
("Channel Common", config.channels),
|
||||||
|
("API Server", config.api),
|
||||||
("Gateway", config.gateway),
|
("Gateway", config.gateway),
|
||||||
("Tools", config.tools),
|
("Tools", config.tools),
|
||||||
("Channel Common", config.channels),
|
|
||||||
]:
|
]:
|
||||||
_print_summary_panel(_summarize_model(model), title)
|
_print_summary_panel(_summarize_model(model), title)
|
||||||
|
|
||||||
|
_pause()
|
||||||
|
|
||||||
|
|
||||||
|
def _pause() -> None:
|
||||||
|
"""Pause for user acknowledgement before clearing the screen."""
|
||||||
|
_get_questionary().text("Press Enter to continue...", default="").ask()
|
||||||
|
|
||||||
|
|
||||||
# --- Main Entry Point ---
|
# --- Main Entry Point ---
|
||||||
|
|
||||||
@ -984,7 +1083,9 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
|
|||||||
choices=[
|
choices=[
|
||||||
"[P] LLM Provider",
|
"[P] LLM Provider",
|
||||||
"[C] Chat Channel",
|
"[C] Chat Channel",
|
||||||
|
"[H] Channel Common",
|
||||||
"[A] Agent Settings",
|
"[A] Agent Settings",
|
||||||
|
"[I] API Server",
|
||||||
"[G] Gateway",
|
"[G] Gateway",
|
||||||
"[T] Tools",
|
"[T] Tools",
|
||||||
"[V] View Configuration Summary",
|
"[V] View Configuration Summary",
|
||||||
@ -1007,7 +1108,9 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
|
|||||||
_MENU_DISPATCH = {
|
_MENU_DISPATCH = {
|
||||||
"[P] LLM Provider": lambda: _configure_providers(config),
|
"[P] LLM Provider": lambda: _configure_providers(config),
|
||||||
"[C] Chat Channel": lambda: _configure_channels(config),
|
"[C] Chat Channel": lambda: _configure_channels(config),
|
||||||
|
"[H] Channel Common": lambda: _configure_general_settings(config, "Channel Common"),
|
||||||
"[A] Agent Settings": lambda: _configure_general_settings(config, "Agent Settings"),
|
"[A] Agent Settings": lambda: _configure_general_settings(config, "Agent Settings"),
|
||||||
|
"[I] API Server": lambda: _configure_general_settings(config, "API Server"),
|
||||||
"[G] Gateway": lambda: _configure_general_settings(config, "Gateway"),
|
"[G] Gateway": lambda: _configure_general_settings(config, "Gateway"),
|
||||||
"[T] Tools": lambda: _configure_general_settings(config, "Tools"),
|
"[T] Tools": lambda: _configure_general_settings(config, "Tools"),
|
||||||
"[V] View Configuration Summary": lambda: _show_summary(config),
|
"[V] View Configuration Summary": lambda: _show_summary(config),
|
||||||
|
|||||||
@ -2,8 +2,19 @@
|
|||||||
|
|
||||||
I am nanobot 🐈, a personal AI assistant.
|
I am nanobot 🐈, a personal AI assistant.
|
||||||
|
|
||||||
I solve problems by doing, not by describing what I would do.
|
## Core Principles
|
||||||
I keep responses short unless depth is asked for.
|
|
||||||
I say what I know, flag what I don't, and never fake confidence.
|
- Solve by doing, not by describing what I would do.
|
||||||
I stay friendly and curious — I'd rather ask a good question than guess wrong.
|
- Keep responses short unless depth is asked for.
|
||||||
I treat the user's time as the scarcest resource, and their trust as the most valuable.
|
- Say what I know, flag what I don't, and never fake confidence.
|
||||||
|
- Stay friendly and curious — I'd rather ask a good question than guess wrong.
|
||||||
|
- Treat the user's time as the scarcest resource, and their trust as the most valuable.
|
||||||
|
|
||||||
|
## Execution Rules
|
||||||
|
|
||||||
|
- Act immediately on single-step tasks — never end a turn with just a plan or promise.
|
||||||
|
- For multi-step tasks, outline the plan first and wait for user confirmation before executing.
|
||||||
|
- Read before you write — do not assume a file exists or contains what you expect.
|
||||||
|
- If a tool call fails, diagnose the error and retry with a different approach before reporting failure.
|
||||||
|
- When information is missing, look it up with tools first. Only ask the user when tools cannot answer.
|
||||||
|
- After multi-step changes, verify the result (re-read the file, run the test, check the output).
|
||||||
|
|||||||
@ -1,7 +1,3 @@
|
|||||||
# nanobot 🐈
|
|
||||||
|
|
||||||
You are nanobot, a helpful AI assistant.
|
|
||||||
|
|
||||||
## Runtime
|
## Runtime
|
||||||
{{ runtime }}
|
{{ runtime }}
|
||||||
|
|
||||||
@ -26,14 +22,6 @@ This conversation is via email. Structure with clear sections. Markdown may not
|
|||||||
Output is rendered in a terminal. Avoid markdown headings and tables. Use plain text with minimal formatting.
|
Output is rendered in a terminal. Avoid markdown headings and tables. Use plain text with minimal formatting.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
## Execution Rules
|
|
||||||
|
|
||||||
- Act, don't narrate. If you can do it with a tool, do it now — never end a turn with just a plan or promise.
|
|
||||||
- Read before you write. Do not assume a file exists or contains what you expect.
|
|
||||||
- If a tool call fails, diagnose the error and retry with a different approach before reporting failure.
|
|
||||||
- When information is missing, look it up with tools first. Only ask the user when tools cannot answer.
|
|
||||||
- After multi-step changes, verify the result (re-read the file, run the test, check the output).
|
|
||||||
|
|
||||||
## Search & Discovery
|
## Search & Discovery
|
||||||
|
|
||||||
- Prefer built-in `grep` / `glob` over `exec` for workspace search.
|
- Prefer built-in `grep` / `glob` over `exec` for workspace search.
|
||||||
|
|||||||
@ -149,16 +149,39 @@ def test_partial_dream_processing_shows_only_remainder(tmp_path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_execution_rules_in_system_prompt(tmp_path) -> None:
|
def test_execution_rules_in_system_prompt(tmp_path) -> None:
|
||||||
"""New execution rules should appear in the system prompt."""
|
"""Execution rules should appear in the system prompt via default SOUL.md."""
|
||||||
|
from nanobot.utils.helpers import sync_workspace_templates
|
||||||
|
|
||||||
workspace = _make_workspace(tmp_path)
|
workspace = _make_workspace(tmp_path)
|
||||||
|
sync_workspace_templates(workspace, silent=True)
|
||||||
builder = ContextBuilder(workspace)
|
builder = ContextBuilder(workspace)
|
||||||
|
|
||||||
prompt = builder.build_system_prompt()
|
prompt = builder.build_system_prompt()
|
||||||
assert "Act, don't narrate" in prompt
|
assert "single-step tasks" in prompt
|
||||||
|
assert "multi-step tasks" in prompt
|
||||||
assert "Read before you write" in prompt
|
assert "Read before you write" in prompt
|
||||||
assert "verify the result" in prompt
|
assert "verify the result" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_identity_has_no_behavioral_instructions(tmp_path) -> None:
|
||||||
|
"""Identity template should not contain behavioral rules or hardcoded name."""
|
||||||
|
workspace = _make_workspace(tmp_path)
|
||||||
|
builder = ContextBuilder(workspace)
|
||||||
|
|
||||||
|
identity = builder._get_identity(channel=None)
|
||||||
|
assert "You are nanobot" not in identity
|
||||||
|
assert "Act, don't narrate" not in identity
|
||||||
|
assert "Execution Rules" not in identity
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_soul_template_contains_execution_rules() -> None:
|
||||||
|
"""Default SOUL.md template must contain execution rules with act/plan layering."""
|
||||||
|
soul = (pkg_files("nanobot") / "templates" / "SOUL.md").read_text(encoding="utf-8")
|
||||||
|
assert "## Execution Rules" in soul
|
||||||
|
assert "single-step tasks" in soul
|
||||||
|
assert "multi-step tasks" in soul
|
||||||
|
|
||||||
|
|
||||||
def test_channel_format_hint_telegram(tmp_path) -> None:
|
def test_channel_format_hint_telegram(tmp_path) -> None:
|
||||||
"""Telegram channel should get messaging-app format hint."""
|
"""Telegram channel should get messaging-app format hint."""
|
||||||
workspace = _make_workspace(tmp_path)
|
workspace = _make_workspace(tmp_path)
|
||||||
|
|||||||
@ -22,6 +22,9 @@ from nanobot.cli.onboard import (
|
|||||||
_format_value,
|
_format_value,
|
||||||
_get_field_display_name,
|
_get_field_display_name,
|
||||||
_get_field_type_info,
|
_get_field_type_info,
|
||||||
|
_get_constraint_hint,
|
||||||
|
_input_text,
|
||||||
|
_validate_field_constraint,
|
||||||
run_onboard,
|
run_onboard,
|
||||||
)
|
)
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
@ -207,6 +210,25 @@ class TestGetFieldTypeInfo:
|
|||||||
assert type_name == "str"
|
assert type_name == "str"
|
||||||
assert inner is None
|
assert inner is None
|
||||||
|
|
||||||
|
def test_literal_type_returns_literal_with_choices(self):
|
||||||
|
"""Literal["a", "b"] should return ("literal", ["a", "b"])."""
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
class Model(BaseModel):
|
||||||
|
mode: Literal["standard", "persistent"] = "standard"
|
||||||
|
|
||||||
|
type_name, inner = _get_field_type_info(Model.model_fields["mode"])
|
||||||
|
assert type_name == "literal"
|
||||||
|
assert inner == ["standard", "persistent"]
|
||||||
|
|
||||||
|
def test_real_provider_retry_mode_field(self):
|
||||||
|
"""Validate against actual AgentDefaults.provider_retry_mode field."""
|
||||||
|
from nanobot.config.schema import AgentDefaults
|
||||||
|
|
||||||
|
type_name, inner = _get_field_type_info(AgentDefaults.model_fields["provider_retry_mode"])
|
||||||
|
assert type_name == "literal"
|
||||||
|
assert inner == ["standard", "persistent"]
|
||||||
|
|
||||||
|
|
||||||
class TestGetFieldDisplayName:
|
class TestGetFieldDisplayName:
|
||||||
"""Tests for _get_field_display_name human-readable name generation."""
|
"""Tests for _get_field_display_name human-readable name generation."""
|
||||||
@ -493,3 +515,448 @@ class TestRunOnboardExitBehavior:
|
|||||||
|
|
||||||
assert result.should_save is False
|
assert result.should_save is False
|
||||||
assert result.config.model_dump(by_alias=True) == initial_config.model_dump(by_alias=True)
|
assert result.config.model_dump(by_alias=True) == initial_config.model_dump(by_alias=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateFieldConstraint:
|
||||||
|
"""Tests for _validate_field_constraint schema-aware input validation."""
|
||||||
|
|
||||||
|
def test_returns_none_when_no_constraints(self):
|
||||||
|
"""Fields without constraints should pass validation."""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class M(BaseModel):
|
||||||
|
name: str = "hello"
|
||||||
|
|
||||||
|
field_info = M.model_fields["name"]
|
||||||
|
from nanobot.cli.onboard import _validate_field_constraint
|
||||||
|
|
||||||
|
assert _validate_field_constraint("anything", field_info) is None
|
||||||
|
|
||||||
|
def test_rejects_value_below_ge_bound(self):
|
||||||
|
"""Value below ge (>=) bound should return error."""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class M(BaseModel):
|
||||||
|
count: int = Field(default=3, ge=0)
|
||||||
|
|
||||||
|
field_info = M.model_fields["count"]
|
||||||
|
from nanobot.cli.onboard import _validate_field_constraint
|
||||||
|
|
||||||
|
result = _validate_field_constraint(-1, field_info)
|
||||||
|
assert result is not None
|
||||||
|
assert "0" in result
|
||||||
|
|
||||||
|
def test_accepts_value_at_ge_bound(self):
|
||||||
|
"""Value exactly at ge (>=) bound should pass."""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class M(BaseModel):
|
||||||
|
count: int = Field(default=3, ge=0)
|
||||||
|
|
||||||
|
field_info = M.model_fields["count"]
|
||||||
|
from nanobot.cli.onboard import _validate_field_constraint
|
||||||
|
|
||||||
|
assert _validate_field_constraint(0, field_info) is None
|
||||||
|
|
||||||
|
def test_rejects_value_above_le_bound(self):
|
||||||
|
"""Value above le (<=) bound should return error."""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class M(BaseModel):
|
||||||
|
retries: int = Field(default=3, le=10)
|
||||||
|
|
||||||
|
field_info = M.model_fields["retries"]
|
||||||
|
from nanobot.cli.onboard import _validate_field_constraint
|
||||||
|
|
||||||
|
result = _validate_field_constraint(11, field_info)
|
||||||
|
assert result is not None
|
||||||
|
assert "10" in result
|
||||||
|
|
||||||
|
def test_accepts_value_at_le_bound(self):
|
||||||
|
"""Value exactly at le (<=) bound should pass."""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class M(BaseModel):
|
||||||
|
retries: int = Field(default=3, le=10)
|
||||||
|
|
||||||
|
field_info = M.model_fields["retries"]
|
||||||
|
from nanobot.cli.onboard import _validate_field_constraint
|
||||||
|
|
||||||
|
assert _validate_field_constraint(10, field_info) is None
|
||||||
|
|
||||||
|
def test_combined_ge_and_le_bounds(self):
|
||||||
|
"""Field with both ge and le should validate both."""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class M(BaseModel):
|
||||||
|
retries: int = Field(default=3, ge=0, le=10)
|
||||||
|
|
||||||
|
field_info = M.model_fields["retries"]
|
||||||
|
from nanobot.cli.onboard import _validate_field_constraint
|
||||||
|
|
||||||
|
assert _validate_field_constraint(5, field_info) is None
|
||||||
|
assert _validate_field_constraint(-1, field_info) is not None
|
||||||
|
assert _validate_field_constraint(11, field_info) is not None
|
||||||
|
|
||||||
|
def test_gt_and_lt_bounds(self):
|
||||||
|
"""Strict inequality bounds (gt, lt) should exclude boundary."""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class M(BaseModel):
|
||||||
|
ratio: float = Field(default=0.5, gt=0.0, lt=1.0)
|
||||||
|
|
||||||
|
field_info = M.model_fields["ratio"]
|
||||||
|
from nanobot.cli.onboard import _validate_field_constraint
|
||||||
|
|
||||||
|
assert _validate_field_constraint(0.5, field_info) is None
|
||||||
|
assert _validate_field_constraint(0.0, field_info) is not None
|
||||||
|
assert _validate_field_constraint(1.0, field_info) is not None
|
||||||
|
|
||||||
|
def test_min_length_constraint(self):
|
||||||
|
"""min_length should validate string/list length."""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class M(BaseModel):
|
||||||
|
name: str = Field(default="x", min_length=1)
|
||||||
|
|
||||||
|
field_info = M.model_fields["name"]
|
||||||
|
from nanobot.cli.onboard import _validate_field_constraint
|
||||||
|
|
||||||
|
assert _validate_field_constraint("a", field_info) is None
|
||||||
|
assert _validate_field_constraint("", field_info) is not None
|
||||||
|
|
||||||
|
def test_max_length_constraint(self):
|
||||||
|
"""max_length should validate string/list length."""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class M(BaseModel):
|
||||||
|
tag: str = Field(default="x", max_length=5)
|
||||||
|
|
||||||
|
field_info = M.model_fields["tag"]
|
||||||
|
from nanobot.cli.onboard import _validate_field_constraint
|
||||||
|
|
||||||
|
assert _validate_field_constraint("abc", field_info) is None
|
||||||
|
assert _validate_field_constraint("abcdef", field_info) is not None
|
||||||
|
|
||||||
|
def test_real_send_max_retries_field(self):
|
||||||
|
"""Validate against the actual ChannelsConfig.send_max_retries field."""
|
||||||
|
from nanobot.config.schema import ChannelsConfig
|
||||||
|
from nanobot.cli.onboard import _validate_field_constraint
|
||||||
|
|
||||||
|
field_info = ChannelsConfig.model_fields["send_max_retries"]
|
||||||
|
assert _validate_field_constraint(3, field_info) is None
|
||||||
|
assert _validate_field_constraint(0, field_info) is None
|
||||||
|
assert _validate_field_constraint(10, field_info) is None
|
||||||
|
assert _validate_field_constraint(-1, field_info) is not None
|
||||||
|
assert _validate_field_constraint(11, field_info) is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetConstraintHint:
|
||||||
|
"""Tests for _get_constraint_hint field display suffix."""
|
||||||
|
|
||||||
|
def test_no_constraints_returns_empty(self):
|
||||||
|
"""Fields without constraints should return empty string."""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class M(BaseModel):
|
||||||
|
name: str = "hello"
|
||||||
|
|
||||||
|
field_info = M.model_fields["name"]
|
||||||
|
assert _get_constraint_hint(field_info) == ""
|
||||||
|
|
||||||
|
def test_ge_le_range(self):
|
||||||
|
"""Field with ge+le should show '(min-max)'."""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class M(BaseModel):
|
||||||
|
retries: int = Field(default=3, ge=0, le=10)
|
||||||
|
|
||||||
|
field_info = M.model_fields["retries"]
|
||||||
|
hint = _get_constraint_hint(field_info)
|
||||||
|
assert "0" in hint
|
||||||
|
assert "10" in hint
|
||||||
|
|
||||||
|
def test_ge_only(self):
|
||||||
|
"""Field with only ge should show '(>= N)'."""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class M(BaseModel):
|
||||||
|
count: int = Field(default=1, ge=0)
|
||||||
|
|
||||||
|
field_info = M.model_fields["count"]
|
||||||
|
hint = _get_constraint_hint(field_info)
|
||||||
|
assert "0" in hint
|
||||||
|
assert ">=" in hint
|
||||||
|
|
||||||
|
def test_le_only(self):
|
||||||
|
"""Field with only le should show '(<= N)'."""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class M(BaseModel):
|
||||||
|
ratio: float = Field(default=1.0, le=100.0)
|
||||||
|
|
||||||
|
field_info = M.model_fields["ratio"]
|
||||||
|
hint = _get_constraint_hint(field_info)
|
||||||
|
assert "100" in hint
|
||||||
|
assert "<=" in hint
|
||||||
|
|
||||||
|
def test_real_send_max_retries_hint(self):
|
||||||
|
"""Actual ChannelsConfig.send_max_retries should show '(0-10)'."""
|
||||||
|
from nanobot.config.schema import ChannelsConfig
|
||||||
|
|
||||||
|
field_info = ChannelsConfig.model_fields["send_max_retries"]
|
||||||
|
hint = _get_constraint_hint(field_info)
|
||||||
|
assert "0" in hint
|
||||||
|
assert "10" in hint
|
||||||
|
|
||||||
|
|
||||||
|
class TestInputTextWithValidation:
|
||||||
|
"""Tests for _input_text integration with constraint validation."""
|
||||||
|
|
||||||
|
def test_rejects_out_of_range_int(self, monkeypatch):
|
||||||
|
"""_input_text with field_info should reject values violating ge/le constraints."""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class M(BaseModel):
|
||||||
|
retries: int = Field(default=3, ge=0, le=10)
|
||||||
|
|
||||||
|
field_info = M.model_fields["retries"]
|
||||||
|
monkeypatch.setattr(
|
||||||
|
onboard_wizard,
|
||||||
|
"_get_questionary",
|
||||||
|
lambda: SimpleNamespace(text=lambda *a, **kw: SimpleNamespace(ask=lambda: "15")),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _input_text("Retries", 3, "int", field_info=field_info)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_accepts_valid_int(self, monkeypatch):
|
||||||
|
"""_input_text with field_info should accept valid constrained values."""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class M(BaseModel):
|
||||||
|
retries: int = Field(default=3, ge=0, le=10)
|
||||||
|
|
||||||
|
field_info = M.model_fields["retries"]
|
||||||
|
monkeypatch.setattr(
|
||||||
|
onboard_wizard,
|
||||||
|
"_get_questionary",
|
||||||
|
lambda: SimpleNamespace(text=lambda *a, **kw: SimpleNamespace(ask=lambda: "5")),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _input_text("Retries", 3, "int", field_info=field_info)
|
||||||
|
assert result == 5
|
||||||
|
|
||||||
|
def test_works_without_field_info(self, monkeypatch):
|
||||||
|
"""_input_text without field_info should work as before (no validation)."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
onboard_wizard,
|
||||||
|
"_get_questionary",
|
||||||
|
lambda: SimpleNamespace(text=lambda *a, **kw: SimpleNamespace(ask=lambda: "42")),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _input_text("Count", 0, "int")
|
||||||
|
assert result == 42
|
||||||
|
|
||||||
|
|
||||||
|
class TestChannelCommonRegistration:
|
||||||
|
"""Tests for Channel Common menu registration."""
|
||||||
|
|
||||||
|
def test_channel_common_in_settings_sections(self):
|
||||||
|
"""Channel Common should be registered in _SETTINGS_SECTIONS."""
|
||||||
|
from nanobot.cli.onboard import _SETTINGS_SECTIONS
|
||||||
|
|
||||||
|
assert "Channel Common" in _SETTINGS_SECTIONS
|
||||||
|
|
||||||
|
def test_channel_common_getter_returns_channels(self):
|
||||||
|
"""Channel Common getter should return config.channels."""
|
||||||
|
from nanobot.cli.onboard import _SETTINGS_GETTER
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
result = _SETTINGS_GETTER["Channel Common"](config)
|
||||||
|
assert result is config.channels
|
||||||
|
|
||||||
|
def test_channel_common_setter_writes_channels(self):
|
||||||
|
"""Channel Common setter should update config.channels."""
|
||||||
|
from nanobot.cli.onboard import _SETTINGS_SETTER
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
original = config.channels
|
||||||
|
new_channels = original.model_copy(deep=True)
|
||||||
|
new_channels.send_tool_hints = True
|
||||||
|
_SETTINGS_SETTER["Channel Common"](config, new_channels)
|
||||||
|
assert config.channels.send_tool_hints is True
|
||||||
|
|
||||||
|
def test_channel_common_edit_preserves_extras(self):
|
||||||
|
"""Editing Channel Common should not lose per-channel extras."""
|
||||||
|
config = Config()
|
||||||
|
config.channels.feishu = {"enabled": True, "appId": "test123"}
|
||||||
|
channels = config.channels.model_copy(deep=True)
|
||||||
|
channels.send_tool_hints = True
|
||||||
|
config.channels = channels
|
||||||
|
assert config.channels.send_tool_hints is True
|
||||||
|
assert config.channels.feishu["appId"] == "test123"
|
||||||
|
|
||||||
|
|
||||||
|
class TestApiServerRegistration:
|
||||||
|
"""Tests for API Server menu registration."""
|
||||||
|
|
||||||
|
def test_api_server_in_settings_sections(self):
|
||||||
|
"""API Server should be registered in _SETTINGS_SECTIONS."""
|
||||||
|
from nanobot.cli.onboard import _SETTINGS_SECTIONS
|
||||||
|
|
||||||
|
assert "API Server" in _SETTINGS_SECTIONS
|
||||||
|
|
||||||
|
def test_api_server_getter_returns_api(self):
|
||||||
|
"""API Server getter should return config.api."""
|
||||||
|
from nanobot.cli.onboard import _SETTINGS_GETTER
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
result = _SETTINGS_GETTER["API Server"](config)
|
||||||
|
assert result is config.api
|
||||||
|
|
||||||
|
def test_api_server_setter_writes_api(self):
|
||||||
|
"""API Server setter should update config.api."""
|
||||||
|
from nanobot.cli.onboard import _SETTINGS_SETTER
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
from nanobot.config.schema import ApiConfig
|
||||||
|
|
||||||
|
new_api = ApiConfig(host="0.0.0.0", port=9999)
|
||||||
|
_SETTINGS_SETTER["API Server"](config, new_api)
|
||||||
|
assert config.api.host == "0.0.0.0"
|
||||||
|
assert config.api.port == 9999
|
||||||
|
|
||||||
|
|
||||||
|
class TestMainMenuUpdate:
|
||||||
|
"""Tests for main menu including new Channel Common and API Server items."""
|
||||||
|
|
||||||
|
def test_main_menu_dispatch_includes_channel_common(self):
|
||||||
|
"""Main menu dispatch should route [H] to Channel Common."""
|
||||||
|
from nanobot.cli.onboard import run_onboard
|
||||||
|
|
||||||
|
# We verify by checking the dispatch table is set up correctly
|
||||||
|
# The menu items are defined inline in run_onboard, so we test
|
||||||
|
# that _configure_general_settings handles the new sections.
|
||||||
|
from nanobot.cli.onboard import _SETTINGS_SECTIONS, _SETTINGS_GETTER, _SETTINGS_SETTER
|
||||||
|
|
||||||
|
assert "Channel Common" in _SETTINGS_SECTIONS
|
||||||
|
assert "Channel Common" in _SETTINGS_GETTER
|
||||||
|
assert "Channel Common" in _SETTINGS_SETTER
|
||||||
|
|
||||||
|
def test_main_menu_dispatch_includes_api_server(self):
|
||||||
|
"""Main menu dispatch should route [I] to API Server."""
|
||||||
|
from nanobot.cli.onboard import _SETTINGS_SECTIONS, _SETTINGS_GETTER, _SETTINGS_SETTER
|
||||||
|
|
||||||
|
assert "API Server" in _SETTINGS_SECTIONS
|
||||||
|
assert "API Server" in _SETTINGS_GETTER
|
||||||
|
assert "API Server" in _SETTINGS_SETTER
|
||||||
|
|
||||||
|
def test_run_onboard_channel_common_edit(self, monkeypatch):
|
||||||
|
"""run_onboard should handle [H] Channel Common correctly."""
|
||||||
|
initial_config = Config()
|
||||||
|
|
||||||
|
responses = iter([
|
||||||
|
"[H] Channel Common",
|
||||||
|
KeyboardInterrupt(),
|
||||||
|
"[S] Save and Exit",
|
||||||
|
])
|
||||||
|
|
||||||
|
class FakePrompt:
|
||||||
|
def __init__(self, response):
|
||||||
|
self.response = response
|
||||||
|
|
||||||
|
def ask(self):
|
||||||
|
if isinstance(self.response, BaseException):
|
||||||
|
raise self.response
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
def fake_select(*_args, **_kwargs):
|
||||||
|
return FakePrompt(next(responses))
|
||||||
|
|
||||||
|
def fake_configure_general_settings(config, section):
|
||||||
|
if section == "Channel Common":
|
||||||
|
config.channels.send_tool_hints = True
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select))
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_configure_general_settings", fake_configure_general_settings)
|
||||||
|
|
||||||
|
result = run_onboard(initial_config=initial_config)
|
||||||
|
|
||||||
|
assert result.should_save is True
|
||||||
|
assert result.config.channels.send_tool_hints is True
|
||||||
|
|
||||||
|
def test_run_onboard_api_server_edit(self, monkeypatch):
|
||||||
|
"""run_onboard should handle [I] API Server correctly."""
|
||||||
|
initial_config = Config()
|
||||||
|
|
||||||
|
responses = iter([
|
||||||
|
"[I] API Server",
|
||||||
|
KeyboardInterrupt(),
|
||||||
|
"[S] Save and Exit",
|
||||||
|
])
|
||||||
|
|
||||||
|
class FakePrompt:
|
||||||
|
def __init__(self, response):
|
||||||
|
self.response = response
|
||||||
|
|
||||||
|
def ask(self):
|
||||||
|
if isinstance(self.response, BaseException):
|
||||||
|
raise self.response
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
def fake_select(*_args, **_kwargs):
|
||||||
|
return FakePrompt(next(responses))
|
||||||
|
|
||||||
|
def fake_configure_general_settings(config, section):
|
||||||
|
if section == "API Server":
|
||||||
|
config.api.port = 9999
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select))
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_configure_general_settings", fake_configure_general_settings)
|
||||||
|
|
||||||
|
result = run_onboard(initial_config=initial_config)
|
||||||
|
|
||||||
|
assert result.should_save is True
|
||||||
|
assert result.config.api.port == 9999
|
||||||
|
|
||||||
|
def test_view_summary_calls_pause(self, monkeypatch):
|
||||||
|
"""[V] View Summary should pause before returning to main menu."""
|
||||||
|
initial_config = Config()
|
||||||
|
pause_called = {"n": 0}
|
||||||
|
|
||||||
|
responses = iter([
|
||||||
|
"[V] View Configuration Summary",
|
||||||
|
"[S] Save and Exit",
|
||||||
|
])
|
||||||
|
|
||||||
|
class FakePrompt:
|
||||||
|
def __init__(self, response):
|
||||||
|
self.response = response
|
||||||
|
|
||||||
|
def ask(self):
|
||||||
|
if isinstance(self.response, BaseException):
|
||||||
|
raise self.response
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
def fake_select(*_args, **_kwargs):
|
||||||
|
return FakePrompt(next(responses))
|
||||||
|
|
||||||
|
def fake_pause():
|
||||||
|
pause_called["n"] += 1
|
||||||
|
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_show_main_menu_header", lambda: None)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "questionary", SimpleNamespace(select=fake_select))
|
||||||
|
# _pause is called inside _show_summary, so we patch it there
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_pause", fake_pause)
|
||||||
|
# Suppress summary output but still call _pause
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_print_summary_panel", lambda *a, **kw: None)
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_get_provider_names", lambda: {})
|
||||||
|
monkeypatch.setattr(onboard_wizard, "_get_channel_names", lambda: {})
|
||||||
|
|
||||||
|
result = run_onboard(initial_config=initial_config)
|
||||||
|
|
||||||
|
assert result.should_save is True
|
||||||
|
assert pause_called["n"] == 1
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user