mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
core/cli: one-shot chat with -p/stdin and exit codes
Add plyngent chat -p/--prompt (and non-TTY stdin), --stream/--no-stream, --yes, --quiet. Non-interactive mode uses NonInteractiveBackend and disables limit prompts. Exit codes: 0 ok, 1 config, 2 cancelled, 3 failed.
This commit is contained in:
@@ -115,14 +115,14 @@ Basedpyright `recommended`. Ruff includes `ANN` (private return types `ANN202` i
|
||||
- G2: `/rename`, `/delete` (confirm), `/export md|json`
|
||||
- G2.5: Click slash registry (`cli/slash.py`) + `awaitlet` for sync Click / async memory; auto `/help`; completer from `slash.list_commands`
|
||||
- G3: multiline `"""` … `"""` input (`cli/input_text.py`); `/edit` via `$EDITOR` (`edit_text_in_editor`)
|
||||
- G4: `plyngent chat -p/--prompt` (+ non-TTY stdin); exit codes 0/1/2/3; `--yes` / non-interactive confirm deny; `--stream/--no-stream`, `--quiet`
|
||||
|
||||
**Planned**
|
||||
|
||||
- **G4 — One-shot**: `plyngent chat -p/--prompt` (and stdin non-TTY); exit codes; non-interactive safety (`--yes` / deny destructive)
|
||||
- **G5 — Hardening**: tool timeouts/defaults review, config error clarity, cancel edges, optional `--log-level`, no secrets in status/export
|
||||
- **G6 — Docs**: real README, example TOML, CLAUDE/help accuracy
|
||||
|
||||
Milestone order: G4 → G5 → G6.
|
||||
Milestone order: G5 → G6.
|
||||
|
||||
- **Phase H**: multi-tenant platform (`router/`, auth, sandboxed tools, web).
|
||||
|
||||
|
||||
+181
-35
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -14,12 +15,15 @@ from plyngent.cli.editor import (
|
||||
open_in_editor,
|
||||
resolve_config_path,
|
||||
)
|
||||
from plyngent.cli.exit_codes import EXIT_CANCELLED, EXIT_OK, EXIT_TURN_FAILED
|
||||
from plyngent.cli.limits import install_cli_limit_hooks
|
||||
from plyngent.cli.repl import run_repl
|
||||
from plyngent.cli.retry import run_user_text_with_retries
|
||||
from plyngent.cli.selection import select_model, select_provider
|
||||
from plyngent.cli.state import ReplState
|
||||
from plyngent.config.models import DatabaseConfig
|
||||
from plyngent.memory import MemoryStore
|
||||
from plyngent.prompting import NonInteractiveBackend, configure_prompting
|
||||
from plyngent.runtime import ProviderNotSupportedError, create_client
|
||||
from plyngent.tools import set_workspace_root
|
||||
|
||||
@@ -33,16 +37,103 @@ def _load_config(config_path: Path | None) -> ConfigStore:
|
||||
return load_config_with_optional_edit(config_path)
|
||||
|
||||
|
||||
def _database_config(store: ConfigStore) -> DatabaseConfig:
|
||||
def _database_config(store: ConfigStore, *, quiet: bool = False) -> DatabaseConfig:
|
||||
raw = dict(store.database)
|
||||
# Prefer a durable file DB so sessions survive CLI restarts.
|
||||
if raw.get("url") in {None, "", ":memory:"} and raw.get("implementation", "sqlite") == "sqlite":
|
||||
db_path = user_data_path("plyngent", ensure_exists=True) / _DEFAULT_DB_FILENAME
|
||||
raw = {**raw, "implementation": "sqlite", "url": str(db_path)}
|
||||
click.secho(f"using database: {db_path}", fg="bright_black")
|
||||
if not quiet:
|
||||
click.secho(f"using database: {db_path}", fg="bright_black", err=True)
|
||||
return msgspec.convert(raw, DatabaseConfig)
|
||||
|
||||
|
||||
def _read_prompt_text(prompt: str | None, *, stdin_isatty: bool) -> str | None:
|
||||
"""Resolve one-shot prompt from ``-p`` and/or non-TTY stdin."""
|
||||
chunks: list[str] = []
|
||||
if prompt is not None and prompt.strip():
|
||||
chunks.append(prompt)
|
||||
if not stdin_isatty:
|
||||
data = sys.stdin.read()
|
||||
if data.strip():
|
||||
chunks.append(data.rstrip("\n"))
|
||||
if not chunks:
|
||||
return None
|
||||
text = "\n".join(chunks).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _setup_workspace_and_hooks(
|
||||
store: ConfigStore,
|
||||
workspace: Path,
|
||||
*,
|
||||
interactive: bool,
|
||||
) -> None:
|
||||
_ = set_workspace_root(workspace)
|
||||
from plyngent.tools import set_path_denylist
|
||||
|
||||
set_path_denylist(store.agent_config.path_denylist or None)
|
||||
if interactive:
|
||||
install_cli_limit_hooks()
|
||||
else:
|
||||
from plyngent.tools.process.pty_session import PtyManager
|
||||
|
||||
PtyManager.set_limit_continue_hook(None)
|
||||
|
||||
|
||||
async def _bind_session(
|
||||
state: ReplState,
|
||||
*,
|
||||
session_id: int | None,
|
||||
new_session: bool,
|
||||
oneshot: bool,
|
||||
quiet: bool,
|
||||
) -> None:
|
||||
if session_id is not None:
|
||||
try:
|
||||
await state.resume_session(session_id)
|
||||
except ValueError as exc:
|
||||
raise click.ClickException(str(exc)) from exc
|
||||
if not quiet and not oneshot:
|
||||
click.echo(
|
||||
f"resumed session {session_id} ({len(state.agent.messages)} messages) workspace={state.workspace}",
|
||||
err=True,
|
||||
)
|
||||
return
|
||||
if new_session or oneshot:
|
||||
await state.new_session()
|
||||
if not quiet and not oneshot:
|
||||
click.echo(
|
||||
f"new session {state.session_id} (workspace={state.workspace})",
|
||||
err=True,
|
||||
)
|
||||
return
|
||||
mode = await state.resume_latest_or_new()
|
||||
if quiet:
|
||||
return
|
||||
if mode == "resume":
|
||||
click.echo(
|
||||
f"resumed latest session {state.session_id} for this workspace "
|
||||
f"({len(state.agent.messages)} messages); use --new for a fresh chat",
|
||||
err=True,
|
||||
)
|
||||
else:
|
||||
click.echo(
|
||||
f"new session {state.session_id} (workspace={state.workspace})",
|
||||
err=True,
|
||||
)
|
||||
|
||||
|
||||
async def _run_oneshot(state: ReplState, prompt_text: str) -> int:
|
||||
try:
|
||||
ok = await run_user_text_with_retries(state.agent, prompt_text, delays=())
|
||||
except asyncio.CancelledError:
|
||||
return EXIT_CANCELLED
|
||||
except KeyboardInterrupt:
|
||||
return EXIT_CANCELLED
|
||||
return EXIT_OK if ok else EXIT_TURN_FAILED
|
||||
|
||||
|
||||
async def _run_chat(
|
||||
*,
|
||||
config_path: Path | None,
|
||||
@@ -53,25 +144,37 @@ async def _run_chat(
|
||||
session_id: int | None,
|
||||
max_rounds: int,
|
||||
new_session: bool,
|
||||
) -> None:
|
||||
prompt_text: str | None,
|
||||
stream: bool,
|
||||
yes: bool,
|
||||
quiet: bool,
|
||||
) -> int:
|
||||
oneshot = prompt_text is not None
|
||||
interactive = not oneshot and sys.stdin.isatty() and sys.stdout.isatty()
|
||||
|
||||
if oneshot:
|
||||
configure_prompting(backend=NonInteractiveBackend())
|
||||
|
||||
store = _load_config(config_path)
|
||||
if store.bad_providers:
|
||||
if store.bad_providers and not quiet:
|
||||
names = ", ".join(sorted(store.bad_providers.keys()))
|
||||
click.secho(f"warning: ignored bad providers: {names}", fg="yellow")
|
||||
click.secho(f"warning: ignored bad providers: {names}", fg="yellow", err=True)
|
||||
|
||||
try:
|
||||
pname, provider = select_provider(store.providers, preferred=provider_name)
|
||||
model_id = select_model(provider, preferred=model)
|
||||
_ = create_client(provider) # fail early if unsupported
|
||||
pname, provider = select_provider(
|
||||
store.providers,
|
||||
preferred=provider_name,
|
||||
interactive=interactive,
|
||||
)
|
||||
model_id = select_model(provider, preferred=model, interactive=interactive)
|
||||
_ = create_client(provider)
|
||||
except ProviderNotSupportedError as exc:
|
||||
raise click.ClickException(str(exc)) from exc
|
||||
|
||||
_ = set_workspace_root(workspace)
|
||||
from plyngent.tools import set_path_denylist
|
||||
_setup_workspace_and_hooks(store, workspace, interactive=interactive)
|
||||
confirm_destructive: bool | None = False if yes else None
|
||||
|
||||
set_path_denylist(store.agent_config.path_denylist or None)
|
||||
install_cli_limit_hooks()
|
||||
memory = await MemoryStore.open(_database_config(store))
|
||||
memory = await MemoryStore.open(_database_config(store, quiet=quiet or oneshot))
|
||||
try:
|
||||
state = ReplState(
|
||||
config=store,
|
||||
@@ -82,29 +185,27 @@ async def _run_chat(
|
||||
model=model_id,
|
||||
tools_enabled=tools,
|
||||
max_rounds=max_rounds,
|
||||
stream_enabled=stream,
|
||||
interactive_limits=interactive,
|
||||
confirm_destructive=confirm_destructive,
|
||||
)
|
||||
click.secho(f"workspace: {state.workspace}", fg="bright_black")
|
||||
if session_id is not None:
|
||||
try:
|
||||
await state.resume_session(session_id)
|
||||
except ValueError as exc:
|
||||
raise click.ClickException(str(exc)) from exc
|
||||
click.echo(
|
||||
f"resumed session {session_id} ({len(state.agent.messages)} messages) workspace={state.workspace}"
|
||||
if not quiet and not oneshot:
|
||||
click.secho(f"workspace: {state.workspace}", fg="bright_black", err=True)
|
||||
|
||||
await _bind_session(
|
||||
state,
|
||||
session_id=session_id,
|
||||
new_session=new_session,
|
||||
oneshot=oneshot,
|
||||
quiet=quiet,
|
||||
)
|
||||
elif new_session:
|
||||
await state.new_session()
|
||||
click.echo(f"new session {state.session_id} (workspace={state.workspace})")
|
||||
else:
|
||||
mode = await state.resume_latest_or_new()
|
||||
if mode == "resume":
|
||||
click.echo(
|
||||
f"resumed latest session {state.session_id} for this workspace "
|
||||
f"({len(state.agent.messages)} messages); use --new for a fresh chat"
|
||||
)
|
||||
else:
|
||||
click.echo(f"new session {state.session_id} (workspace={state.workspace})")
|
||||
|
||||
if oneshot:
|
||||
assert prompt_text is not None
|
||||
return await _run_oneshot(state, prompt_text)
|
||||
|
||||
await run_repl(state)
|
||||
return EXIT_OK
|
||||
finally:
|
||||
await memory.close()
|
||||
|
||||
@@ -147,6 +248,31 @@ def main() -> None:
|
||||
show_default=True,
|
||||
help="Max tool-loop rounds per user turn.",
|
||||
)
|
||||
@click.option(
|
||||
"-p",
|
||||
"--prompt",
|
||||
"prompt",
|
||||
default=None,
|
||||
help="One-shot user message (non-interactive). Also reads stdin when not a TTY.",
|
||||
)
|
||||
@click.option(
|
||||
"--stream/--no-stream",
|
||||
default=True,
|
||||
show_default=True,
|
||||
help="Stream model output (one-shot and REPL default).",
|
||||
)
|
||||
@click.option(
|
||||
"--yes",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Allow destructive tools without confirm (one-shot / non-interactive).",
|
||||
)
|
||||
@click.option(
|
||||
"--quiet",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Less status noise on stderr.",
|
||||
)
|
||||
def chat_cmd(
|
||||
config_path: Path | None,
|
||||
provider_name: str | None,
|
||||
@@ -156,16 +282,30 @@ def chat_cmd(
|
||||
session_id: int | None,
|
||||
new_session: bool, # noqa: FBT001
|
||||
max_rounds: int,
|
||||
prompt: str | None,
|
||||
stream: bool, # noqa: FBT001
|
||||
yes: bool, # noqa: FBT001
|
||||
quiet: bool, # noqa: FBT001
|
||||
) -> None:
|
||||
"""Interactive chat REPL with optional tools and session memory."""
|
||||
"""Interactive chat REPL, or one-shot with ``-p`` / stdin.
|
||||
|
||||
Exit codes (one-shot): 0 ok, 1 config/usage, 2 cancelled, 3 turn failed.
|
||||
"""
|
||||
if max_rounds < 1:
|
||||
msg = "--max-rounds must be >= 1"
|
||||
raise click.ClickException(msg)
|
||||
if session_id is not None and new_session:
|
||||
msg = "use either --session or --new, not both"
|
||||
raise click.ClickException(msg)
|
||||
|
||||
prompt_text = _read_prompt_text(prompt, stdin_isatty=sys.stdin.isatty())
|
||||
if prompt is None and prompt_text is None and not sys.stdin.isatty():
|
||||
# Non-TTY with empty stdin and no -p: still require an explicit prompt.
|
||||
msg = "no prompt: pass -p/--prompt or pipe text on stdin"
|
||||
raise click.ClickException(msg)
|
||||
|
||||
root = workspace if workspace is not None else Path.cwd()
|
||||
asyncio.run(
|
||||
code = asyncio.run(
|
||||
_run_chat(
|
||||
config_path=config_path,
|
||||
provider_name=provider_name,
|
||||
@@ -175,8 +315,14 @@ def chat_cmd(
|
||||
session_id=session_id,
|
||||
max_rounds=max_rounds,
|
||||
new_session=new_session,
|
||||
prompt_text=prompt_text,
|
||||
stream=stream,
|
||||
yes=yes,
|
||||
quiet=quiet,
|
||||
)
|
||||
)
|
||||
if code != EXIT_OK:
|
||||
raise SystemExit(code)
|
||||
|
||||
|
||||
@main.command("providers")
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Process exit codes for ``plyngent chat`` (one-shot and fatal paths)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
EXIT_OK = 0
|
||||
EXIT_ERROR = 1 # config / usage / fatal
|
||||
EXIT_CANCELLED = 2
|
||||
EXIT_TURN_FAILED = 3 # API / retry exhausted / incomplete turn
|
||||
@@ -185,9 +185,14 @@ async def run_turn_with_retries(
|
||||
return True
|
||||
|
||||
|
||||
async def run_user_text_with_retries(agent: ChatAgent, text: str) -> bool:
|
||||
"""Send a new user message with auto-retry."""
|
||||
return await run_turn_with_retries(agent, starter=lambda: agent.run(text))
|
||||
async def run_user_text_with_retries(
|
||||
agent: ChatAgent,
|
||||
text: str,
|
||||
*,
|
||||
delays: tuple[float, ...] = DEFAULT_RETRY_DELAYS_SECONDS,
|
||||
) -> bool:
|
||||
"""Send a new user message with auto-retry (empty ``delays`` = no auto-retry)."""
|
||||
return await run_turn_with_retries(agent, starter=lambda: agent.run(text), delays=delays)
|
||||
|
||||
|
||||
async def retry_pending_with_retries(agent: ChatAgent) -> bool:
|
||||
|
||||
@@ -14,6 +14,7 @@ def select_provider(
|
||||
providers: Mapping[str, Provider],
|
||||
*,
|
||||
preferred: str | None = None,
|
||||
interactive: bool = True,
|
||||
) -> tuple[str, Provider]:
|
||||
"""Pick a provider by name or interactive prompt."""
|
||||
if not providers:
|
||||
@@ -29,9 +30,13 @@ def select_provider(
|
||||
|
||||
if len(names) == 1:
|
||||
name = names[0]
|
||||
click.echo(f"Using provider: {name}")
|
||||
click.echo(f"Using provider: {name}", err=True)
|
||||
return name, providers[name]
|
||||
|
||||
if not interactive:
|
||||
msg = f"multiple providers; pass --provider ({', '.join(names)})"
|
||||
raise click.ClickException(msg)
|
||||
|
||||
click.echo("Available providers:")
|
||||
for index, name in enumerate(names, start=1):
|
||||
preset = type(providers[name]).__struct_config__.tag
|
||||
@@ -44,6 +49,7 @@ def select_model(
|
||||
provider: Provider,
|
||||
*,
|
||||
preferred: str | None = None,
|
||||
interactive: bool = True,
|
||||
) -> str:
|
||||
"""Pick a model id from provider.models or free-form prompt."""
|
||||
model_names = sorted(provider.models.keys())
|
||||
@@ -55,9 +61,16 @@ def select_model(
|
||||
|
||||
if len(model_names) == 1:
|
||||
model = model_names[0]
|
||||
click.echo(f"Using model: {model}")
|
||||
click.echo(f"Using model: {model}", err=True)
|
||||
return model
|
||||
|
||||
if not interactive:
|
||||
if model_names:
|
||||
msg = f"multiple models; pass --model ({', '.join(model_names)})"
|
||||
else:
|
||||
msg = "no models listed; pass --model"
|
||||
raise click.ClickException(msg)
|
||||
|
||||
if model_names:
|
||||
click.echo("Available models:")
|
||||
for index, name in enumerate(model_names, start=1):
|
||||
|
||||
@@ -31,6 +31,10 @@ class ReplState:
|
||||
max_rounds: int = DEFAULT_MAX_ROUNDS
|
||||
stream_enabled: bool = True
|
||||
verbose: bool = False
|
||||
# One-shot / scripts: never prompt to raise tool-loop limits.
|
||||
interactive_limits: bool = True
|
||||
# When False, skip destructive-tool confirms (e.g. --yes).
|
||||
confirm_destructive: bool | None = None
|
||||
# Set by /edit; REPL sends as the next user turn then clears.
|
||||
pending_user_text: str | None = None
|
||||
client: ChatClient = field(init=False)
|
||||
@@ -56,14 +60,18 @@ class ReplState:
|
||||
raise RuntimeError(msg)
|
||||
return key
|
||||
|
||||
def _confirm_destructive(self) -> bool:
|
||||
if self.confirm_destructive is not None:
|
||||
return self.confirm_destructive
|
||||
return self.config.agent_config.confirm_destructive
|
||||
|
||||
def _tool_registry(self) -> ToolRegistry | None:
|
||||
if not self.tools_enabled:
|
||||
return None
|
||||
from plyngent.cli.limits import prompt_confirm_tool_async
|
||||
from plyngent.tools.danger import classify_danger
|
||||
|
||||
agent_cfg = self.config.agent_config
|
||||
if agent_cfg.confirm_destructive:
|
||||
if self._confirm_destructive():
|
||||
return ToolRegistry(
|
||||
list(DEFAULT_TOOLS),
|
||||
danger=classify_danger,
|
||||
@@ -76,6 +84,7 @@ class ReplState:
|
||||
|
||||
agent_cfg = self.config.agent_config
|
||||
system_prompt = agent_cfg.system_prompt or None
|
||||
on_limit = prompt_continue_limit_async if self.interactive_limits else None
|
||||
return ChatAgent(
|
||||
self.client,
|
||||
model=self.model,
|
||||
@@ -83,7 +92,7 @@ class ReplState:
|
||||
memory=self.memory,
|
||||
session_id=self.session_id,
|
||||
max_rounds=self.max_rounds,
|
||||
on_limit=prompt_continue_limit_async,
|
||||
on_limit=on_limit,
|
||||
stream=self.stream_enabled,
|
||||
system_prompt=system_prompt,
|
||||
max_tool_result_chars=agent_cfg.max_tool_result_chars,
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Literal, overload
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from plyngent.cli.app import _read_prompt_text, main
|
||||
from plyngent.cli.exit_codes import EXIT_OK
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
AssistantChatMessage,
|
||||
ChatCompletionChoice,
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionResponse,
|
||||
ChatCompletionsParam,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
|
||||
def test_read_prompt_flag_only() -> None:
|
||||
assert _read_prompt_text("hello", stdin_isatty=True) == "hello"
|
||||
assert _read_prompt_text(" ", stdin_isatty=True) is None
|
||||
assert _read_prompt_text(None, stdin_isatty=True) is None
|
||||
|
||||
|
||||
def test_read_prompt_stdin(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class FakeStdin:
|
||||
def read(self) -> str:
|
||||
return "from stdin\n"
|
||||
|
||||
monkeypatch.setattr("plyngent.cli.app.sys.stdin", FakeStdin())
|
||||
assert _read_prompt_text(None, stdin_isatty=False) == "from stdin"
|
||||
assert _read_prompt_text("flag", stdin_isatty=False) == "flag\nfrom stdin"
|
||||
|
||||
|
||||
def test_chat_oneshot_requires_provider_flags(tmp_path: Path) -> None:
|
||||
config = tmp_path / "plyngent.toml"
|
||||
_ = config.write_text(
|
||||
"""
|
||||
[providers.a]
|
||||
preset = "openai-compatible"
|
||||
url = "https://example.com/v1"
|
||||
access_key_or_token = "sk"
|
||||
|
||||
[providers.a.models]
|
||||
"m1" = {}
|
||||
|
||||
[providers.b]
|
||||
preset = "openai-compatible"
|
||||
url = "https://example.com/v1"
|
||||
access_key_or_token = "sk"
|
||||
|
||||
[providers.b.models]
|
||||
"m2" = {}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["chat", "--config", str(config), "-p", "hi", "--workspace", str(tmp_path)],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "provider" in result.output.lower() or "provider" in (result.stderr or "").lower()
|
||||
|
||||
|
||||
def test_chat_oneshot_success(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = tmp_path / "plyngent.toml"
|
||||
_ = config.write_text(
|
||||
"""
|
||||
[providers.local]
|
||||
preset = "openai-compatible"
|
||||
url = "https://example.com/v1"
|
||||
access_key_or_token = "sk"
|
||||
|
||||
[providers.local.models]
|
||||
"tiny" = {}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
class DummyClient:
|
||||
@overload
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
|
||||
) -> ChatCompletionResponse: ...
|
||||
|
||||
@overload
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: Literal[True]
|
||||
) -> AsyncIterator[ChatCompletionChunk]: ...
|
||||
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: bool = False
|
||||
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
||||
del param
|
||||
if stream:
|
||||
|
||||
async def empty() -> AsyncIterator[ChatCompletionChunk]:
|
||||
if False:
|
||||
yield # type: ignore[misc]
|
||||
return
|
||||
|
||||
return empty()
|
||||
return ChatCompletionResponse(
|
||||
id="1",
|
||||
object="chat.completion",
|
||||
created=0,
|
||||
model="tiny",
|
||||
choices=[
|
||||
ChatCompletionChoice(
|
||||
index=0,
|
||||
message=AssistantChatMessage(content="pong"),
|
||||
logprobs={},
|
||||
finish_reason="stop",
|
||||
)
|
||||
],
|
||||
system_fingerprint="",
|
||||
usage={},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("plyngent.cli.app.create_client", lambda _p: DummyClient())
|
||||
monkeypatch.setattr(
|
||||
"plyngent.cli.state.create_client",
|
||||
lambda _p: DummyClient(),
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"chat",
|
||||
"--config",
|
||||
str(config),
|
||||
"--provider",
|
||||
"local",
|
||||
"--model",
|
||||
"tiny",
|
||||
"-p",
|
||||
"ping",
|
||||
"--no-stream",
|
||||
"--workspace",
|
||||
str(tmp_path),
|
||||
"--quiet",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == EXIT_OK
|
||||
assert "pong" in result.output
|
||||
|
||||
|
||||
def test_chat_help_mentions_prompt() -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["chat", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "--prompt" in result.output or "-p" in result.output
|
||||
assert "Exit codes" in result.output or "one-shot" in result.output.lower()
|
||||
Reference in New Issue
Block a user