diff --git a/README.md b/README.md index 15f902d..d42fa13 100644 --- a/README.md +++ b/README.md @@ -88,9 +88,6 @@ Credentials land in `~/.agentrun/config.json` under the `default` profile. Use ```bash $ ar super-agent run --prompt "You are a Python expert" -Loading model services... -? Select model service: svc-tongyi -? Select model: qwen-max Creating super agent: super-agent-tmp-20260420213045 ... Ready. Type your message (/help for commands). @@ -128,9 +125,6 @@ metadata: description: "My personal assistant" spec: prompt: "You are my helpful assistant" - model: - service: svc-tongyi - name: qwen-max tools: - mcp-time-sa skills: [] diff --git a/README_zh.md b/README_zh.md index caf12c3..c7204b5 100644 --- a/README_zh.md +++ b/README_zh.md @@ -87,9 +87,6 @@ ar config set region cn-hangzhou ```bash $ ar super-agent run --prompt "你是一个 Python 专家" -Loading model services... -? Select model service: svc-tongyi -? Select model: qwen-max Creating super agent: super-agent-tmp-20260420213045 ... Ready. Type your message (/help for commands). @@ -127,9 +124,6 @@ metadata: description: "我的助手" spec: prompt: "你是我的得力助手" - model: - service: svc-tongyi - name: qwen-max tools: - mcp-time-sa skills: [] diff --git a/docs/en/super-agent.md b/docs/en/super-agent.md index 2ea176b..1294eba 100644 --- a/docs/en/super-agent.md +++ b/docs/en/super-agent.md @@ -59,8 +59,6 @@ ar sa run [options] |------|------|----------|---------|-------------| | `--name` | string | no | `super-agent-tmp-` | Agent name. Explicit name → persistent; auto-name → still persistent but clearly temporary. | | `--prompt`, `-p` | string | no | `You are a helpful assistant.` | System prompt. | -| `--model-service` | string | no | TTY-picker | ModelService name. Required if stdin is not a TTY or `--no-input` is set. | -| `--model` | string | no | TTY-picker | Model name within the ModelService. | | `--tool` | multi | no | | Tool name, repeatable. | | `--skill` | multi | no | | Skill name, repeatable. | | `--sandbox` | multi | no | | Sandbox name, repeatable. | @@ -69,23 +67,21 @@ ar sa run [options] | `--message`, `-m` | string | no | | Initial message — sent right after entering the REPL. | | `--raw` | flag | no | false | Force raw SSE JSON-line output. | | `--text-only` | flag | no | false | Only show assistant text (hide tool calls). | -| `--no-input` | flag | no | false | Disable interactive pickers; any missing required arg fails the command. | +| `--no-input` | flag | no | false | Deprecated, no-op. Kept for backward script compatibility. | ### Examples ```bash -# Zero-config — CLI picks ModelService/Model interactively +# Zero-config — server picks a default model ar sa run # Explicit prompt and an initial message ar sa run -p "You write concise Python" -m "Implement FizzBuzz" -# Non-interactive (scripts / CI) +# With tools enabled ar sa run \ - --model-service svc-tongyi --model qwen-max \ --prompt "You are an assistant" \ - --tool mcp-time-sa \ - --no-input + --tool mcp-time-sa # Name it and keep it around ar sa run --name my-helper -p "You are my helper" @@ -232,8 +228,6 @@ ar sa create --name [options] | `--name` | string | yes | Agent name (globally unique). | | `--description` | string | no | Description. | | `--prompt`, `-p` | string | no | System prompt. | -| `--model-service` | string | no | ModelService name. | -| `--model` | string | no | Model name. | | `--tool` | multi | no | Tool name, repeatable. | | `--skill` | multi | no | Skill name, repeatable. | | `--sandbox` | multi | no | Sandbox name, repeatable. | @@ -244,12 +238,10 @@ ar sa create --name [options] ```bash ar sa create --name my-helper \ - -p "You are my assistant" \ - --model-service svc-tongyi --model qwen-max + -p "You are my assistant" ar sa create --name researcher \ -p "Deep research assistant" \ - --model-service svc-tongyi --model qwen-max \ --tool web-search --tool mcp-time-sa \ --skill data-analyzer --skill report-generator ``` @@ -311,8 +303,6 @@ ar sa update [options] | `NAME` | positional | Agent to update. | | `--description` | string | New description. | | `--prompt`, `-p` | string | New prompt. | -| `--model-service` | string | New ModelService. | -| `--model` | string | New model name. | | `--tool` | multi | Replacement tool list. | | `--skill` | multi | Replacement skill list. | | `--sandbox` | multi | Replacement sandbox list. | @@ -331,7 +321,6 @@ both fails with exit code `2`. ```bash ar sa update my-helper -p "You are a concise helper" -ar sa update my-helper --model-service svc-openai --model gpt-4o ar sa update my-helper --tool web-search --tool calculator ar sa update my-helper --clear-tools ``` @@ -405,9 +394,6 @@ metadata: spec: prompt: | # optional You are a helpful assistant. - model: # optional (but required to actually invoke) - service: svc-tongyi - name: qwen-max tools: - mcp-time-sa skills: [] @@ -423,8 +409,6 @@ spec: | `metadata.name` | `name` | | `metadata.description` | `description` | | `spec.prompt` | `prompt` | -| `spec.model.service` | `model_service_name` | -| `spec.model.name` | `model_name` | | `spec.tools` | `tools` | | `spec.skills` | `skills` | | `spec.sandboxes` | `sandboxes` | @@ -442,7 +426,6 @@ metadata: name: doc-writer spec: prompt: "You write clear docs" - model: { service: svc-tongyi, name: qwen-max } --- apiVersion: agentrun/v1 kind: SuperAgent @@ -450,7 +433,6 @@ metadata: name: code-reviewer spec: prompt: "You are a Python reviewer" - model: { service: svc-tongyi, name: qwen-max } ``` `apply -f` processes documents in order; if any fails, already-succeeded agents diff --git a/docs/zh/super-agent.md b/docs/zh/super-agent.md index 3f18c52..8e37c39 100644 --- a/docs/zh/super-agent.md +++ b/docs/zh/super-agent.md @@ -58,8 +58,6 @@ ar sa run [options] |------|------|------|------|------| | `--name` | string | 否 | `super-agent-tmp-` | Agent 名。显式命名 → 持久化;自动名 → 同样持久化,仅命名方便识别。 | | `--prompt`、`-p` | string | 否 | `You are a helpful assistant.` | 系统提示词。 | -| `--model-service` | string | 否 | TTY 交互选 | ModelService 名;非 TTY 或 `--no-input` 时必填。 | -| `--model` | string | 否 | TTY 交互选 | ModelService 内的模型名。 | | `--tool` | multi | 否 | | 工具名,可重复。 | | `--skill` | multi | 否 | | 技能名,可重复。 | | `--sandbox` | multi | 否 | | 沙箱名,可重复。 | @@ -68,23 +66,21 @@ ar sa run [options] | `--message`、`-m` | string | 否 | | 初始消息 —— 进入 REPL 后立刻发送。 | | `--raw` | flag | 否 | false | 强制 raw SSE JSON 行输出。 | | `--text-only` | flag | 否 | false | 只显示 Assistant 文本(隐藏工具调用)。 | -| `--no-input` | flag | 否 | false | 禁用交互式选择器;缺必填参数直接报错。 | +| `--no-input` | flag | 否 | false | 已弃用,行为为空操作;仅保留以兼容旧脚本。 | ### 示例 ```bash -# 零配置 —— ModelService / 模型交互式选择 +# 零配置 —— 服务端选用默认 model ar sa run # 指定 prompt + 初始消息 ar sa run -p "你是简洁风格的 Python 程序员" -m "写个 FizzBuzz" -# 非交互(脚本/CI) +# 启用工具 ar sa run \ - --model-service svc-tongyi --model qwen-max \ --prompt "你是助手" \ - --tool mcp-time-sa \ - --no-input + --tool mcp-time-sa # 命名让 Agent 持久保留 ar sa run --name my-helper -p "你是我的助手" @@ -228,8 +224,6 @@ ar sa create --name [options] | `--name` | string | 是 | 全局唯一的 Agent 名。 | | `--description` | string | 否 | 描述。 | | `--prompt`、`-p` | string | 否 | 系统提示词。 | -| `--model-service` | string | 否 | ModelService 名。 | -| `--model` | string | 否 | 模型名。 | | `--tool` | multi | 否 | 工具名,可重复。 | | `--skill` | multi | 否 | 技能名,可重复。 | | `--sandbox` | multi | 否 | 沙箱名,可重复。 | @@ -240,12 +234,10 @@ ar sa create --name [options] ```bash ar sa create --name my-helper \ - -p "你是我的助手" \ - --model-service svc-tongyi --model qwen-max + -p "你是我的助手" ar sa create --name researcher \ -p "深度调研助手" \ - --model-service svc-tongyi --model qwen-max \ --tool web-search --tool mcp-time-sa \ --skill data-analyzer --skill report-generator ``` @@ -307,8 +299,6 @@ ar sa update [options] | `NAME` | 位置参数 | 要更新的 Agent。 | | `--description` | string | 新描述。 | | `--prompt`、`-p` | string | 新 prompt。 | -| `--model-service` | string | 新 ModelService。 | -| `--model` | string | 新模型名。 | | `--tool` | multi | 替换 tools 列表。 | | `--skill` | multi | 替换 skills 列表。 | | `--sandbox` | multi | 替换 sandboxes 列表。 | @@ -326,7 +316,6 @@ ar sa update [options] ```bash ar sa update my-helper -p "简洁风格的助手" -ar sa update my-helper --model-service svc-openai --model gpt-4o ar sa update my-helper --tool web-search --tool calculator ar sa update my-helper --clear-tools ``` @@ -399,9 +388,6 @@ metadata: spec: prompt: | # 可选 你是一个得力助手。 - model: # 可选(但发起调用前必须有) - service: svc-tongyi - name: qwen-max tools: - mcp-time-sa skills: [] @@ -417,8 +403,6 @@ spec: | `metadata.name` | `name` | | `metadata.description` | `description` | | `spec.prompt` | `prompt` | -| `spec.model.service` | `model_service_name` | -| `spec.model.name` | `model_name` | | `spec.tools` | `tools` | | `spec.skills` | `skills` | | `spec.sandboxes` | `sandboxes` | @@ -436,7 +420,6 @@ metadata: name: doc-writer spec: prompt: "你擅长撰写清晰的技术文档" - model: { service: svc-tongyi, name: qwen-max } --- apiVersion: agentrun/v1 kind: SuperAgent @@ -444,7 +427,6 @@ metadata: name: code-reviewer spec: prompt: "你是资深 Python Reviewer" - model: { service: svc-tongyi, name: qwen-max } ``` `apply -f` 按文档顺序处理;任一失败会导致后续中断,**但已成功的 Agent 不会回滚**。 diff --git a/pyproject.toml b/pyproject.toml index 3748669..71d79ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ ] dependencies = [ - "agentrun-sdk[core]>=0.0.33", + "agentrun-sdk[core]>=0.0.34", "pyyaml>=6.0", "questionary>=2.0", ] diff --git a/src/agentrun_cli/_utils/super_agent_picker.py b/src/agentrun_cli/_utils/super_agent_picker.py deleted file mode 100644 index 5c6d32f..0000000 --- a/src/agentrun_cli/_utils/super_agent_picker.py +++ /dev/null @@ -1,227 +0,0 @@ -"""Interactive picker for ModelService / model selection. - -Used by ``ar sa run`` when the user hasn't supplied both ``--model-service`` -and ``--model`` and is on an interactive TTY. - -When stdin is not a TTY (e.g. piped / CI), non-interactive resolution is -required; missing flags raise ``PickerInputError``. - -The interactive UI uses ``questionary`` for arrow-key navigation with a -fixed page size of 10. On pages beyond the first, ``▼ Next page`` / -``▲ Previous page`` sentinels are appended; a ``🔍 Search all…`` sentinel -always appears when there are multiple pages, dropping the user into a -fuzzy autocomplete over the full list. -""" - -from __future__ import annotations - -from typing import Any, Callable, Iterable, List, Optional, Tuple - -import click - -PAGE_SIZE = 10 - -_SENTINEL_NEXT = object() -_SENTINEL_PREV = object() -_SENTINEL_SEARCH = object() - - -class PickerInputError(Exception): - """Raised when model resolution is impossible (no TTY + missing flags).""" - - -def _default_services_loader(cfg): - """Fetch ModelService list via SDK (sync).""" - from agentrun.model import ModelService, ModelType - - return ModelService.list_all(model_type=ModelType.LLM, config=cfg) - - -def _default_selector(title: str, choices: List[Tuple[str, Any]]) -> Any: - """Arrow-key selector with page-size 10 and fuzzy search. - - ``choices`` is a list of ``(label, value)`` tuples. Returns the chosen - value, or raises ``PickerInputError`` if the user cancels (Esc / Ctrl-C). - """ - import questionary - - if not choices: - raise PickerInputError("No choices to select from.") - - total = len(choices) - if total <= PAGE_SIZE: - answer = questionary.select( - title, - choices=[ - questionary.Choice(label, value=value) - for label, value in choices - ], - ).ask() - if answer is None: - raise PickerInputError("Selection cancelled.") - return answer - - total_pages = (total + PAGE_SIZE - 1) // PAGE_SIZE - page = 0 - while True: - start = page * PAGE_SIZE - end = min(start + PAGE_SIZE, total) - page_choices = [ - questionary.Choice(label, value=value) - for label, value in choices[start:end] - ] - extras: list = [] - if page + 1 < total_pages: - extras.append(questionary.Choice( - f"▼ Next page ({page + 2}/{total_pages})", - value=_SENTINEL_NEXT, - )) - if page > 0: - extras.append(questionary.Choice( - f"▲ Previous page ({page}/{total_pages})", - value=_SENTINEL_PREV, - )) - extras.append(questionary.Choice( - "🔍 Search all…", value=_SENTINEL_SEARCH, - )) - - answer = questionary.select( - f"{title} [{page + 1}/{total_pages}]", - choices=page_choices + extras, - ).ask() - if answer is None: - raise PickerInputError("Selection cancelled.") - if answer is _SENTINEL_NEXT: - page += 1 - continue - if answer is _SENTINEL_PREV: - page -= 1 - continue - if answer is _SENTINEL_SEARCH: - picked = _fuzzy_pick_all(title, choices) - if picked is _SENTINEL_PREV: - continue - return picked - return answer - - -def _fuzzy_pick_all(title: str, choices: List[Tuple[str, Any]]) -> Any: - """Fuzzy-autocomplete over the full list; Esc returns a sentinel.""" - import questionary - - labels = [label for label, _ in choices] - label_to_value = {label: value for label, value in choices} - typed = questionary.autocomplete( - f"{title} (type to filter, Enter to select, Esc to cancel)", - choices=labels, - match_middle=True, - ignore_case=True, - ).ask() - if typed is None or typed not in label_to_value: - if typed and typed not in label_to_value: - click.echo(f"No exact match for {typed!r}; showing list.", err=True) - return _SENTINEL_PREV - return label_to_value[typed] - - -def _get_svc_name(svc) -> str: - return str( - getattr(svc, "model_service_name", None) or getattr(svc, "name", "") or "" - ) - - -def _get_model_names(svc) -> list: - ps = getattr(svc, "provider_settings", None) - if ps is None: - return [] - names = getattr(ps, "model_names", None) - return list(names or []) - - -def _svc_choices(services: Iterable) -> List[Tuple[str, Any]]: - out: List[Tuple[str, Any]] = [] - for svc in services: - provider = getattr(svc, "provider", "") - label = f"{_get_svc_name(svc)} (provider: {provider})" - out.append((label, svc)) - return out - - -def _model_choices(model_names: Iterable[str]) -> List[Tuple[str, Any]]: - return [(name, name) for name in model_names] - - -def resolve_model( - *, - cli_service: Optional[str], - cli_model: Optional[str], - is_tty: bool, - services_loader: Optional[Callable] = None, - cfg=None, - selector: Optional[Callable[[str, List[Tuple[str, Any]]], Any]] = None, -) -> Tuple[str, str]: - """Resolve (model_service_name, model_name) from CLI flags + interactive prompt. - - Priority: - 1. Both flags given → use as-is, no lookup. - 2. Non-TTY & any missing → raise PickerInputError. - 3. TTY → fetch ModelService list, prompt user via ``selector``. - """ - if cli_service and cli_model: - return cli_service, cli_model - - if not is_tty: - raise PickerInputError( - "--model-service and --model are required when stdin is not a TTY." - ) - - loader = services_loader or _default_services_loader - services = list(loader(cfg) or []) - if not services: - raise PickerInputError( - "No model services found. Run `ar model create ...` first." - ) - - pick = selector or _default_selector - - # ── pick service ── - if cli_service: - matched = [s for s in services if _get_svc_name(s) == cli_service] - if not matched: - raise PickerInputError( - f"Model service {cli_service!r} not found." - ) - chosen_service = matched[0] - elif len(services) == 1: - chosen_service = services[0] - click.echo( - f"Using only available model service: " - f"{_get_svc_name(chosen_service)}" - ) - else: - chosen_service = pick("Select model service", _svc_choices(services)) - - # ── pick model ── - model_names = _get_model_names(chosen_service) - if not model_names: - raise PickerInputError( - f"Model service {_get_svc_name(chosen_service)!r} exposes no models." - ) - - if cli_model: - if cli_model not in model_names: - raise PickerInputError( - f"Model {cli_model!r} not exposed by service " - f"{_get_svc_name(chosen_service)!r}." - ) - chosen_model = cli_model - elif len(model_names) == 1: - chosen_model = model_names[0] - click.echo(f"Using only available model: {chosen_model}") - else: - chosen_model = pick( - f"Select model from {_get_svc_name(chosen_service)}", - _model_choices(model_names), - ) - - return _get_svc_name(chosen_service), chosen_model diff --git a/src/agentrun_cli/commands/super_agent/run_cmd.py b/src/agentrun_cli/commands/super_agent/run_cmd.py index 6932c65..950b115 100644 --- a/src/agentrun_cli/commands/super_agent/run_cmd.py +++ b/src/agentrun_cli/commands/super_agent/run_cmd.py @@ -7,10 +7,6 @@ from agentrun_cli._utils.config import build_sdk_config from agentrun_cli._utils.error import handle_errors -from agentrun_cli._utils.super_agent_picker import ( - PickerInputError, - resolve_model, -) from agentrun_cli._utils.super_agent_render import pick_render_mode from agentrun_cli._utils.super_agent_repl import ReplConfig, run_repl from agentrun_cli.commands.super_agent._helpers import ctx_cfg @@ -39,9 +35,9 @@ def _auto_name() -> str: @click.option("--prompt", "-p", default="You are a helpful assistant.", help="System prompt.") @click.option("--model-service", default=None, - help="ModelService name (TTY prompts interactively if omitted).") + help="ModelService name (optional; server picks a default if omitted).") @click.option("--model", default=None, - help="Model name (TTY prompts interactively if omitted).") + help="Model name (optional; server picks a default if omitted).") @click.option("--tool", "tools", multiple=True) @click.option("--skill", "skills", multiple=True) @click.option("--sandbox", "sandboxes", multiple=True) @@ -51,7 +47,7 @@ def _auto_name() -> str: @click.option("--raw", is_flag=True, default=False) @click.option("--text-only", is_flag=True, default=False) @click.option("--no-input", is_flag=True, default=False, - help="Skip interactive prompts; required args must be given.") + help="Deprecated: no-op. Kept for backward script compatibility.") @click.pass_context @handle_errors def run_cmd(ctx, name, prompt, model_service, model, @@ -61,17 +57,6 @@ def run_cmd(ctx, name, prompt, model_service, model, profile, region = ctx_cfg(ctx) cfg = build_sdk_config(profile_name=profile, region=region) - is_tty = ( - sys.stdin.isatty() and sys.stdout.isatty() and not no_input - ) - try: - resolved_svc, resolved_model = resolve_model( - cli_service=model_service, cli_model=model, - is_tty=is_tty, cfg=cfg, - ) - except PickerInputError as e: - raise click.UsageError(str(e)) - resolved_name = name or _auto_name() client = _get_client_cls()(config=cfg) @@ -80,8 +65,8 @@ def run_cmd(ctx, name, prompt, model_service, model, agent = client.create( name=resolved_name, prompt=prompt, - model_service_name=resolved_svc, - model_name=resolved_model, + model_service_name=model_service, + model_name=model, tools=list(tools), skills=list(skills), sandboxes=list(sandboxes), diff --git a/superagent.yaml b/superagent.yaml new file mode 100644 index 0000000..4615153 --- /dev/null +++ b/superagent.yaml @@ -0,0 +1,15 @@ +apiVersion: agentrun/v1 +kind: SuperAgent +metadata: + name: superagent-jingsu-cli-yaml + description: "我的超级Agent" +spec: + prompt: "你是我的超级Agent助手,可以帮我完成各种任务" + tools: + - mcp-time-sa + skills: + - skill-wechat-article-search + sandboxes: [] + workspaces: [] + subAgents: + - agent-quick-vdyo9 diff --git a/tests/integration/test_super_agent_run_cmd.py b/tests/integration/test_super_agent_run_cmd.py index e68088e..a3e3c30 100644 --- a/tests/integration/test_super_agent_run_cmd.py +++ b/tests/integration/test_super_agent_run_cmd.py @@ -73,15 +73,26 @@ def test_run_with_all_flags(self, tmp_path): assert kwargs["model_service_name"] == "svc-tongyi" assert kwargs["model_name"] == "qwen-max" - def test_run_missing_model_no_input(self): + def test_run_missing_model_no_input(self, tmp_path): + """No model flags + --no-input must succeed; SDK gets None for both.""" agent = _make_agent() (client_p, cfg_p), client = _patch_all(agent) - with client_p, cfg_p: + state_file = tmp_path / "state.json" + with client_p, cfg_p, \ + patch("agentrun_cli._utils.super_agent_state.STATE_FILE", state_file): runner = CliRunner() result = runner.invoke( - cli, ["sa", "run", "--prompt", "hi", "--no-input"], + cli, + ["sa", "run", + "--prompt", "hi", + "--no-input", + "--raw"], + input="/exit\n", ) - assert result.exit_code != 0 + assert result.exit_code == 0, result.output + kwargs = client.create.call_args.kwargs + assert kwargs["model_service_name"] is None + assert kwargs["model_name"] is None def test_run_auto_name_generated(self, tmp_path): agent = _make_agent() @@ -141,3 +152,42 @@ def test_run_with_tools(self, tmp_path): assert result.exit_code == 0, result.output kwargs = client.create.call_args.kwargs assert kwargs["tools"] == ["t1", "t2"] + + def test_run_no_model_flags_passes_none(self, tmp_path): + """Without --model-service / --model the CLI must pass None through to SDK.""" + agent = _make_agent() + (client_p, cfg_p), client = _patch_all(agent) + state_file = tmp_path / "state.json" + with client_p, cfg_p, \ + patch("agentrun_cli._utils.super_agent_state.STATE_FILE", state_file): + runner = CliRunner() + result = runner.invoke( + cli, + ["sa", "run", + "--prompt", "hi", + "--raw"], + input="/exit\n", + ) + assert result.exit_code == 0, result.output + kwargs = client.create.call_args.kwargs + assert kwargs["model_service_name"] is None + assert kwargs["model_name"] is None + + def test_run_raw_and_text_only_is_usage_error(self, tmp_path): + """--raw + --text-only are mutually exclusive; pick_render_mode raises ValueError -> UsageError.""" + agent = _make_agent() + (client_p, cfg_p), client = _patch_all(agent) + state_file = tmp_path / "state.json" + with client_p, cfg_p, \ + patch("agentrun_cli._utils.super_agent_state.STATE_FILE", state_file): + runner = CliRunner() + result = runner.invoke( + cli, + ["sa", "run", + "--prompt", "hi", + "--model-service", "svc", "--model", "m", + "--raw", "--text-only"], + ) + assert result.exit_code != 0 + # click.UsageError -> exit code 2 by default + assert result.exit_code == 2 or "raw" in result.output.lower() diff --git a/tests/unit/test_super_agent_picker.py b/tests/unit/test_super_agent_picker.py deleted file mode 100644 index 0e549b3..0000000 --- a/tests/unit/test_super_agent_picker.py +++ /dev/null @@ -1,375 +0,0 @@ -"""Unit tests for interactive model picker.""" - -import sys -from types import SimpleNamespace -from unittest.mock import MagicMock, patch - -import pytest - -from agentrun_cli._utils import super_agent_picker as picker_mod -from agentrun_cli._utils.super_agent_picker import ( - PAGE_SIZE, - PickerInputError, - _default_selector, - _default_services_loader, - _fuzzy_pick_all, - _get_model_names, - _get_svc_name, - _model_choices, - _svc_choices, - resolve_model, -) - - -def _svc(name, model_names, provider="tongyi"): - return SimpleNamespace( - model_service_name=name, - provider=provider, - provider_settings=SimpleNamespace(model_names=model_names), - ) - - -class TestResolveModelNonInteractive: - - def test_both_flags_given(self): - service, model = resolve_model( - cli_service="svc-a", cli_model="m-a", - is_tty=False, services_loader=None, cfg=None, - ) - assert service == "svc-a" - assert model == "m-a" - - def test_non_tty_missing_flags_raises(self): - with pytest.raises(PickerInputError): - resolve_model( - cli_service=None, cli_model=None, - is_tty=False, services_loader=None, cfg=None, - ) - - def test_non_tty_missing_model_raises(self): - with pytest.raises(PickerInputError): - resolve_model( - cli_service="svc-a", cli_model=None, - is_tty=False, services_loader=None, cfg=None, - ) - - -class TestResolveModelInteractive: - - def test_single_service_single_model_auto_pick(self): - loader = MagicMock(return_value=[_svc("only-svc", ["only-model"])]) - service, model = resolve_model( - cli_service=None, cli_model=None, - is_tty=True, services_loader=loader, cfg=None, - selector=MagicMock(), - ) - assert service == "only-svc" - assert model == "only-model" - - def test_multiple_services_prompt(self): - svc_a = _svc("svc-a", ["m-a1", "m-a2"]) - svc_b = _svc("svc-b", ["m-b1"]) - loader = MagicMock(return_value=[svc_a, svc_b]) - - # First call: pick svc-b. Second call: not invoked (svc-b has 1 model). - selector = MagicMock(side_effect=[svc_b]) - service, model = resolve_model( - cli_service=None, cli_model=None, - is_tty=True, services_loader=loader, cfg=None, - selector=selector, - ) - assert service == "svc-b" - assert model == "m-b1" - - def test_multiple_models_prompt(self): - svc_a = _svc("svc-a", ["m-a1", "m-a2", "m-a3"]) - loader = MagicMock(return_value=[svc_a]) - selector = MagicMock(return_value="m-a2") - service, model = resolve_model( - cli_service=None, cli_model=None, - is_tty=True, services_loader=loader, cfg=None, - selector=selector, - ) - assert service == "svc-a" - assert model == "m-a2" - # Only model picker invoked (1 service auto-picks). - assert selector.call_count == 1 - title, choices = selector.call_args.args - assert "svc-a" in title - assert [label for label, _ in choices] == ["m-a1", "m-a2", "m-a3"] - - def test_service_selector_receives_service_choices(self): - svc_a = _svc("svc-a", ["m-a1", "m-a2"]) - svc_b = _svc("svc-b", ["m-b1", "m-b2"]) - loader = MagicMock(return_value=[svc_a, svc_b]) - selector = MagicMock(side_effect=[svc_a, "m-a1"]) - resolve_model( - cli_service=None, cli_model=None, - is_tty=True, services_loader=loader, cfg=None, - selector=selector, - ) - first_title, first_choices = selector.call_args_list[0].args - assert "service" in first_title.lower() - assert any("provider: tongyi" in label for label, _ in first_choices) - - def test_empty_list_raises(self): - loader = MagicMock(return_value=[]) - with pytest.raises(PickerInputError) as exc: - resolve_model( - cli_service=None, cli_model=None, - is_tty=True, services_loader=loader, cfg=None, - selector=MagicMock(), - ) - assert "ar model" in str(exc.value) - - def test_service_given_but_not_found(self): - loader = MagicMock(return_value=[_svc("svc-a", ["m-a"])]) - with pytest.raises(PickerInputError) as exc: - resolve_model( - cli_service="svc-other", cli_model=None, - is_tty=True, services_loader=loader, cfg=None, - selector=MagicMock(), - ) - assert "not found" in str(exc.value) - - def test_service_has_no_models(self): - loader = MagicMock(return_value=[_svc("svc-a", [])]) - with pytest.raises(PickerInputError) as exc: - resolve_model( - cli_service=None, cli_model=None, - is_tty=True, services_loader=loader, cfg=None, - selector=MagicMock(), - ) - assert "no models" in str(exc.value).lower() - - def test_partial_service_only_prompts_for_model(self): - """cli_service given, cli_model missing → look up svc, then prompt.""" - loader = MagicMock(return_value=[_svc("svc-a", ["m-a1", "m-a2"])]) - selector = MagicMock(return_value="m-a2") - service, model = resolve_model( - cli_service="svc-a", cli_model=None, - is_tty=True, services_loader=loader, cfg=None, - selector=selector, - ) - assert service == "svc-a" - assert model == "m-a2" - - def test_cli_model_in_service_no_prompt(self): - """If cli_model is valid for looked-up service, use directly.""" - loader = MagicMock(return_value=[_svc("svc-a", ["m-a1", "m-a2"])]) - selector = MagicMock() - service, model = resolve_model( - cli_service=None, cli_model="m-a2", - is_tty=True, services_loader=loader, - cfg=None, selector=selector, - ) - assert service == "svc-a" - assert model == "m-a2" - # Single service auto-picked; cli_model validated directly. - selector.assert_not_called() - - def test_cli_model_not_in_service_raises(self): - loader = MagicMock(return_value=[_svc("svc-a", ["m-a1"])]) - with pytest.raises(PickerInputError) as exc: - resolve_model( - cli_service=None, cli_model="nope", - is_tty=True, services_loader=loader, - cfg=None, selector=MagicMock(), - ) - assert "nope" in str(exc.value) - - -class TestInternalHelpers: - - def test_get_svc_name_from_model_service_name(self): - svc = SimpleNamespace(model_service_name="svc-a") - assert _get_svc_name(svc) == "svc-a" - - def test_get_svc_name_from_name(self): - svc = SimpleNamespace(name="svc-b") - assert _get_svc_name(svc) == "svc-b" - - def test_get_model_names_no_provider_settings(self): - svc = SimpleNamespace() - assert _get_model_names(svc) == [] - - def test_get_model_names_none_list(self): - svc = SimpleNamespace( - provider_settings=SimpleNamespace(model_names=None), - ) - assert _get_model_names(svc) == [] - - def test_get_model_names_returns_list(self): - svc = SimpleNamespace( - provider_settings=SimpleNamespace(model_names=["a", "b"]), - ) - assert _get_model_names(svc) == ["a", "b"] - - def test_svc_choices_label_contains_provider(self): - svc_a = _svc("svc-a", ["m1"], provider="dashscope") - out = _svc_choices([svc_a]) - assert len(out) == 1 - label, value = out[0] - assert "svc-a" in label - assert "dashscope" in label - assert value is svc_a - - def test_model_choices(self): - assert _model_choices(["m1", "m2"]) == [("m1", "m1"), ("m2", "m2")] - - -class TestDefaultLoader: - - def test_default_loader_calls_sdk(self): - """_default_services_loader proxies to ModelService.list_all.""" - fake_svc = [MagicMock()] - fake_model_service = MagicMock() - fake_model_service.list_all.return_value = fake_svc - fake_model_type = MagicMock() - fake_model_type.LLM = "llm-enum" - with patch.dict(sys.modules, { - "agentrun.model": SimpleNamespace( - ModelService=fake_model_service, - ModelType=fake_model_type, - ), - }): - result = _default_services_loader(cfg="cfg-obj") - assert result == fake_svc - fake_model_service.list_all.assert_called_once_with( - model_type="llm-enum", config="cfg-obj", - ) - - -class TestDefaultSelector: - """questionary-backed default selector.""" - - def _patch_questionary(self, select_returns=None, autocomplete_returns=None): - """Return a MagicMock questionary module; queued .ask() results.""" - mod = MagicMock() - - def make_question(return_values): - q = MagicMock() - q.ask = MagicMock(side_effect=list(return_values)) - return q - - def select(title, choices): - return make_question([select_returns.pop(0)]) - - def autocomplete(title, choices, **kwargs): - return make_question([autocomplete_returns.pop(0)]) - - if select_returns is not None: - mod.select = MagicMock(side_effect=select) - if autocomplete_returns is not None: - mod.autocomplete = MagicMock(side_effect=autocomplete) - mod.Choice = lambda label, value: SimpleNamespace( - label=label, value=value, - ) - return mod - - def test_empty_choices_raises(self): - with pytest.raises(PickerInputError): - _default_selector("t", []) - - def test_single_page_returns_selected_value(self): - choices = [(f"label-{i}", f"v-{i}") for i in range(5)] - mod = self._patch_questionary(select_returns=["v-3"]) - with patch.dict(sys.modules, {"questionary": mod}): - assert _default_selector("Title", choices) == "v-3" - mod.select.assert_called_once() - - def test_single_page_cancel_raises(self): - choices = [("a", 1), ("b", 2)] - mod = self._patch_questionary(select_returns=[None]) - with patch.dict(sys.modules, {"questionary": mod}): - with pytest.raises(PickerInputError): - _default_selector("Title", choices) - - def test_paginated_next_then_select(self): - total = PAGE_SIZE * 2 + 3 # 23 items → 3 pages - choices = [(f"label-{i}", f"v-{i}") for i in range(total)] - # first call: pick next sentinel; second: pick v-15 - mod = self._patch_questionary( - select_returns=[picker_mod._SENTINEL_NEXT, "v-15"], - ) - with patch.dict(sys.modules, {"questionary": mod}): - assert _default_selector("T", choices) == "v-15" - assert mod.select.call_count == 2 - # First call title mentions [1/3] - first_title = mod.select.call_args_list[0].args[0] - assert "[1/3]" in first_title - - def test_paginated_prev_navigation(self): - total = PAGE_SIZE * 2 + 1 # 21 items → 3 pages - choices = [(f"l-{i}", f"v-{i}") for i in range(total)] - mod = self._patch_questionary( - select_returns=[ - picker_mod._SENTINEL_NEXT, # page 1 → 2 - picker_mod._SENTINEL_PREV, # page 2 → 1 - "v-0", # page 1 pick - ], - ) - with patch.dict(sys.modules, {"questionary": mod}): - assert _default_selector("T", choices) == "v-0" - assert mod.select.call_count == 3 - - def test_paginated_cancel_raises(self): - total = PAGE_SIZE + 5 # 15 items → 2 pages - choices = [(f"l-{i}", i) for i in range(total)] - mod = self._patch_questionary(select_returns=[None]) - with patch.dict(sys.modules, {"questionary": mod}): - with pytest.raises(PickerInputError): - _default_selector("T", choices) - - def test_paginated_search_hit_returns_value(self): - total = PAGE_SIZE + 2 # 12 items → 2 pages - choices = [(f"l-{i}", f"v-{i}") for i in range(total)] - mod = self._patch_questionary( - select_returns=[picker_mod._SENTINEL_SEARCH], - autocomplete_returns=["l-7"], - ) - with patch.dict(sys.modules, {"questionary": mod}): - assert _default_selector("T", choices) == "v-7" - - def test_paginated_search_miss_returns_to_list(self): - total = PAGE_SIZE + 2 - choices = [(f"l-{i}", f"v-{i}") for i in range(total)] - mod = self._patch_questionary( - select_returns=[picker_mod._SENTINEL_SEARCH, "v-0"], - autocomplete_returns=["not-in-list"], - ) - with patch.dict(sys.modules, {"questionary": mod}): - assert _default_selector("T", choices) == "v-0" - - def test_paginated_search_cancel_returns_to_list(self): - total = PAGE_SIZE + 2 - choices = [(f"l-{i}", f"v-{i}") for i in range(total)] - mod = self._patch_questionary( - select_returns=[picker_mod._SENTINEL_SEARCH, "v-1"], - autocomplete_returns=[None], - ) - with patch.dict(sys.modules, {"questionary": mod}): - assert _default_selector("T", choices) == "v-1" - - -class TestFuzzyPickAll: - - def test_fuzzy_hit(self): - mod = MagicMock() - q = MagicMock() - q.ask = MagicMock(return_value="label-2") - mod.autocomplete = MagicMock(return_value=q) - with patch.dict(sys.modules, {"questionary": mod}): - out = _fuzzy_pick_all( - "T", [("label-1", 1), ("label-2", 2), ("label-3", 3)], - ) - assert out == 2 - - def test_fuzzy_cancel(self): - mod = MagicMock() - q = MagicMock() - q.ask = MagicMock(return_value=None) - mod.autocomplete = MagicMock(return_value=q) - with patch.dict(sys.modules, {"questionary": mod}): - out = _fuzzy_pick_all("T", [("l", 1)]) - assert out is picker_mod._SENTINEL_PREV diff --git a/tests/unit/test_super_agent_render.py b/tests/unit/test_super_agent_render.py index bc001d8..f1da162 100644 --- a/tests/unit/test_super_agent_render.py +++ b/tests/unit/test_super_agent_render.py @@ -189,6 +189,51 @@ def test_invalid_json_handled(self, capsys): assert "not-json" not in out +class TestFlushAndErrorEdges: + + def _stream_that_raises_on_flush(self): + class S: + def __init__(self): + self.buf = [] + + def write(self, s): + self.buf.append(s) + + def flush(self): + raise OSError("flush failed") + + def isatty(self): + return False + return S() + + def test_finish_swallows_flush_error_pretty(self): + """`finish()` flush failures are silently swallowed.""" + s = self._stream_that_raises_on_flush() + r = StreamRenderer(RenderMode.PRETTY, use_color=False, stream=s) + # Should not raise even though flush() throws. + r.finish() + + def test_finish_with_envelope_swallows_flush_error_raw(self): + """RAW envelope finisher: both flushes (finish + envelope) tolerate errors.""" + s = self._stream_that_raises_on_flush() + r = StreamRenderer(RenderMode.RAW, use_color=False, stream=s) + r.set_conversation_id("c-1") + # Should not raise even though both flush() calls throw. + r.finish_with_envelope() + joined = "".join(s.buf) + assert "envelope" in joined + + def test_color_error_path(self, capsys): + """RUN_ERROR with color enabled hits the styled branch of `_write_error`.""" + r = StreamRenderer(RenderMode.PRETTY, use_color=True) + r.feed(_ev("RUN_ERROR", '{"message":"boom"}')) + r.finish() + out = capsys.readouterr().out + # ANSI escape present and message visible. + assert "\x1b[" in out + assert "boom" in out + + class TestEventTypeInData: """The real server streams SSE with empty event: field. diff --git a/tests/unit/test_super_agent_repl.py b/tests/unit/test_super_agent_repl.py index 6c1a6e0..3fe6d81 100644 --- a/tests/unit/test_super_agent_repl.py +++ b/tests/unit/test_super_agent_repl.py @@ -242,3 +242,26 @@ def test_empty_line_ignored(self): ): run_repl(agent, cfg) assert agent.invoke_async.await_count == 0 + + +class TestReplStateWriteFailure: + + def test_state_writer_exception_warns_but_does_not_crash(self, capsys): + """If STATE_FILE_WRITER raises, REPL prints a warning to stderr and continues.""" + events = [[_ev("RUN_FINISHED", '{}')]] + agent = _make_agent(events) + cfg = ReplConfig( + agent_name="x", + render_mode=RenderMode.RAW, + input_fn=MagicMock(side_effect=["hello", "/exit"]), + ) + with patch( + "agentrun_cli._utils.super_agent_repl.STATE_FILE_WRITER", + side_effect=OSError("disk full"), + ): + final = run_repl(agent, cfg) + # Conversation id from the stream still propagates (state set before write). + assert final == "conv-xxx" + captured = capsys.readouterr() + assert "failed to persist" in captured.err + assert "disk full" in captured.err