fix: route bare prompts through the modern Typer run engine (fixes #3310)

A bare free-text prompt (`praisonai "do something"`) now forwards to the
same modern `run` path as `praisonai run`, inheriting session continuity,
--output modes, the credential gate and permissions. `.yaml` workflows and
flag-bearing legacy invocations remain on the legacy dispatcher.

Co-authored-by: MervinPraison <[email protected]>
This commit is contained in:
praisonai-triage-agent[bot]andMervinPraison committed 2026-07-23 10:24:08 +00:00
1 parent 7494bbadc9
commit 82f55c97b0
2 files changed
+98 -6

No files matched your search

+33 -2
View File
@@ -74,6 +74,31 @@ def _find_first_command(argv):
return None
def _looks_like_bare_prompt(argv, first_cmd):
"""Return True when argv is a bare free-text prompt for the modern `run` path.
A *bare prompt* is a first positional token that is neither a Typer command
(already handled upstream) nor a ``.yaml``/``.yml`` file token, invoked with
no ``-``/``--`` flags at all. Such an invocation expresses the same intent as
``praisonai run "<prompt>"`` and should reach the modern Typer ``run`` engine
(session continuity, ``--output`` modes, credential gate, permissions).
The rule is deliberately conservative: any flag present keeps the invocation
on the legacy dispatcher, because the legacy argparse surface owns a large set
of flags (``--framework``, ``--auto``, ``--call``, ``--serve``, ...) that
``run`` does not implement. ``.yaml``/``.yml`` workflows also stay on legacy.
"""
if not first_cmd:
return False
# Any flag anywhere → legacy owns the large deprecated/legacy flag surface.
if any(arg.startswith("-") for arg in argv):
return False
# YAML workflow files stay on the legacy multi-framework path.
if first_cmd.lower().endswith((".yaml", ".yml")):
return False
return True
def _run_typer(argv):
"""Dispatch to the Typer CLI app."""
import os
@@ -149,7 +174,8 @@ def main():
2. --help / -h → Typer help (global or command-level)
3. No arguments → Typer interactive TUI
4. First arg is a Typer cmd→ Typer (auto-discovered from app.py)
5. Everything else → Legacy (prompt, .yaml, deprecated flags)
5. Bare free-text prompt → Typer `run` (modern engine)
6. Everything else → Legacy (.yaml, deprecated flags)
"""
argv = sys.argv[1:]
@@ -180,8 +206,13 @@ def main():
if first_cmd in _get_typer_commands():
# Known Typer command → Typer
_run_typer(argv)
elif _looks_like_bare_prompt(argv, first_cmd):
# Bare free-text prompt → modern Typer `run` engine (same as
# `praisonai run "<prompt>"`), inheriting session continuity,
# --output modes, the credential gate and permissions.
_run_typer(["run", *argv])
else:
# Prompt, YAML file, or legacy invocation → legacy
# YAML workflow or legacy/deprecated-flag invocation → legacy
_run_legacy(argv)
@@ -72,6 +72,49 @@ class TestFindFirstCommand(unittest.TestCase):
)
class TestLooksLikeBarePrompt(unittest.TestCase):
"""``_looks_like_bare_prompt`` gates the modern `run` forwarder.
True only for a flagless, non-YAML first positional token — so a bare
``praisonai "hello"`` reaches Typer `run` while ``.yaml`` workflows and any
flag-bearing (legacy) invocation stay on the legacy dispatcher.
"""
def test_plain_prompt_is_bare(self):
argv = ["Build a weather agent"]
first = dispatcher._find_first_command(argv)
self.assertTrue(dispatcher._looks_like_bare_prompt(argv, first))
def test_single_word_prompt_is_bare(self):
argv = ["hello"]
first = dispatcher._find_first_command(argv)
self.assertTrue(dispatcher._looks_like_bare_prompt(argv, first))
def test_yaml_token_is_not_bare(self):
for token in ("agents.yaml", "agents.yml", "AGENTS.YAML"):
argv = [token]
first = dispatcher._find_first_command(argv)
self.assertFalse(
dispatcher._looks_like_bare_prompt(argv, first),
f"{token!r} should not be treated as a bare prompt",
)
def test_any_flag_is_not_bare(self):
argv = ["Do something", "--framework", "crewai"]
first = dispatcher._find_first_command(argv)
self.assertFalse(dispatcher._looks_like_bare_prompt(argv, first))
def test_leading_flag_is_not_bare(self):
# A leading flag means the first positional was discovered past a flag;
# such invocations belong to legacy, not the bare `run` forwarder.
argv = ["--verbose", "hello"]
first = dispatcher._find_first_command(argv)
self.assertFalse(dispatcher._looks_like_bare_prompt(argv, first))
def test_no_positional_is_not_bare(self):
self.assertFalse(dispatcher._looks_like_bare_prompt([], None))
class TestGetTyperCommandsCache(unittest.TestCase):
"""``_get_typer_commands`` caches its result under a lock and does
not poison the cache on failure."""
@@ -236,8 +279,23 @@ class TestMainRouting(unittest.TestCase):
run_typer.assert_called_once()
run_legacy.assert_not_called()
def test_freetext_prompt_routes_to_legacy(self):
def test_freetext_prompt_routes_to_typer_run(self):
# A bare free-text prompt now reaches the modern Typer `run` engine
# (session continuity, --output modes, credential gate) instead of the
# legacy path — rewritten to ``run <prompt>``.
sys.argv = ["praisonai", "Create a weather app"]
with mock.patch.object(
dispatcher, "_get_typer_commands", return_value={"chat", "ui"}
), mock.patch.object(dispatcher, "_run_typer") as run_typer, \
mock.patch.object(dispatcher, "_run_legacy") as run_legacy:
dispatcher.main()
run_typer.assert_called_once_with(["run", "Create a weather app"])
run_legacy.assert_not_called()
def test_bare_prompt_with_legacy_flag_routes_to_legacy(self):
# A prompt combined with any flag stays on legacy: the legacy argparse
# surface owns the large deprecated/legacy flag set that `run` lacks.
sys.argv = ["praisonai", "Create a weather app", "--framework", "crewai"]
with mock.patch.object(
dispatcher, "_get_typer_commands", return_value={"chat", "ui"}
), mock.patch.object(dispatcher, "_run_typer") as run_typer, \
@@ -259,15 +317,18 @@ class TestMainRouting(unittest.TestCase):
run_legacy.assert_called_once()
run_typer.assert_not_called()
def test_unknown_command_routes_to_legacy(self):
def test_unknown_single_token_routes_to_typer_run(self):
# A lone unknown token (no flags, not a YAML path) is treated as a bare
# prompt and forwarded to the modern Typer `run` engine — matching the
# prior legacy behaviour of running an unknown word as a one-shot prompt.
sys.argv = ["praisonai", "totally-unknown"]
with mock.patch.object(
dispatcher, "_get_typer_commands", return_value={"chat"}
), mock.patch.object(dispatcher, "_run_typer") as run_typer, \
mock.patch.object(dispatcher, "_run_legacy") as run_legacy:
dispatcher.main()
run_legacy.assert_called_once()
run_typer.assert_not_called()
run_typer.assert_called_once_with(["run", "totally-unknown"])
run_legacy.assert_not_called()
class TestRunLegacyArgvRestoration(unittest.TestCase):