From bf5d828c7b8d43b01a3934dc18d871d5ac6ce4ba Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Sat, 19 Jul 2025 12:55:45 +0200 Subject: [PATCH 01/13] clai: Add ability to continue last conversation --- clai/README.md | 3 +- pydantic_ai_slim/pydantic_ai/_cli.py | 54 ++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/clai/README.md b/clai/README.md index 635e381fc0..d44857ad6d 100644 --- a/clai/README.md +++ b/clai/README.md @@ -53,7 +53,7 @@ Either way, running `clai` will start an interactive session where you can chat ## Help ``` -usage: clai [-h] [-m [MODEL]] [-a AGENT] [-l] [-t [CODE_THEME]] [--no-stream] [--version] [prompt] +usage: clai [-h] [-m [MODEL]] [-a AGENT] [-l] [-t [CODE_THEME]] [-c] [--no-stream] [--version] [prompt] Pydantic AI CLI v... @@ -74,6 +74,7 @@ options: -l, --list-models List all available models and exit -t [CODE_THEME], --code-theme [CODE_THEME] Which colors to use for code, can be "dark", "light" or any theme from pygments.org/styles/. Defaults to "dark" which works well on dark terminals. + -c, --continue Continue last conversation, if any, instead of starting a new one. --no-stream Disable streaming from the model --version Show version and exit ``` diff --git a/pydantic_ai_slim/pydantic_ai/_cli.py b/pydantic_ai_slim/pydantic_ai/_cli.py index 3f596405a0..6ba0dbf06f 100644 --- a/pydantic_ai_slim/pydantic_ai/_cli.py +++ b/pydantic_ai_slim/pydantic_ai/_cli.py @@ -12,13 +12,14 @@ from pathlib import Path from typing import Any, cast +from pydantic import ValidationError from typing_inspection.introspection import get_literal_values from . import __version__ from ._run_context import AgentDepsT from .agent import AbstractAgent, Agent from .exceptions import UserError -from .messages import ModelMessage +from .messages import ModelMessage, ModelMessagesTypeAdapter from .models import KnownModelName, infer_model from .output import OutputDataT @@ -53,6 +54,7 @@ """ PROMPT_HISTORY_FILENAME = 'prompt-history.txt' +LAST_CONVERSATION_FILENAME = 'last-conversation.json' class SimpleCodeBlock(CodeBlock): @@ -146,6 +148,13 @@ def cli( # noqa: C901 help='Which colors to use for code, can be "dark", "light" or any theme from pygments.org/styles/. Defaults to "dark" which works well on dark terminals.', default='dark', ) + parser.add_argument( + '-c', + '--continue', + dest='continue_', + action='store_true', + help='Continue last conversation, if any, instead of starting a new one.', + ) parser.add_argument('--no-stream', action='store_true', help='Disable streaming from the model') parser.add_argument('--version', action='store_true', help='Show version and exit') @@ -205,19 +214,42 @@ def cli( # noqa: C901 else: code_theme = args.code_theme # pragma: no cover + try: + history = load_last_conversation() if args.continue_ else None + except ValidationError: + console.print( + '[red]Error loading last conversation, it is corrupted or invalid. Starting a new conversation.[/red]' + ) + history = None + if prompt := cast(str, args.prompt): try: - asyncio.run(ask_agent(agent, prompt, stream, console, code_theme)) + asyncio.run(ask_agent(agent, prompt, stream, console, code_theme, messages=history)) except KeyboardInterrupt: pass return 0 try: - return asyncio.run(run_chat(stream, agent, console, code_theme, prog_name)) + return asyncio.run(run_chat(stream, agent, console, code_theme, prog_name, history=history)) except KeyboardInterrupt: # pragma: no cover return 0 +def store_last_conversation(messages: list[ModelMessage], config_dir: Path | None = None) -> None: + last_conversation_path = (config_dir or PYDANTIC_AI_HOME) / LAST_CONVERSATION_FILENAME + last_conversation_path.parent.mkdir(parents=True, exist_ok=True) + last_conversation_path.write_bytes(ModelMessagesTypeAdapter.dump_json(messages)) + + +def load_last_conversation(config_dir: Path | None = None) -> list[ModelMessage] | None: + last_conversation_path = (config_dir or PYDANTIC_AI_HOME) / LAST_CONVERSATION_FILENAME + + if not last_conversation_path.exists(): + return None + + return ModelMessagesTypeAdapter.validate_json(last_conversation_path.read_text()) + + async def run_chat( stream: bool, agent: AbstractAgent[AgentDepsT, OutputDataT], @@ -226,6 +258,7 @@ async def run_chat( prog_name: str, config_dir: Path | None = None, deps: AgentDepsT = None, + history: list[ModelMessage] | None = None, ) -> int: prompt_history_path = (config_dir or PYDANTIC_AI_HOME) / PROMPT_HISTORY_FILENAME prompt_history_path.parent.mkdir(parents=True, exist_ok=True) @@ -233,7 +266,7 @@ async def run_chat( session: PromptSession[Any] = PromptSession(history=FileHistory(str(prompt_history_path))) multiline = False - messages: list[ModelMessage] = [] + messages: list[ModelMessage] = history or [] while True: try: @@ -252,7 +285,7 @@ async def run_chat( return exit_value else: try: - messages = await ask_agent(agent, text, stream, console, code_theme, deps, messages) + messages = await ask_agent(agent, text, stream, console, code_theme, deps, messages, config_dir) except CancelledError: # pragma: no cover console.print('[dim]Interrupted[/dim]') except Exception as e: # pragma: no cover @@ -270,6 +303,7 @@ async def ask_agent( code_theme: str, deps: AgentDepsT = None, messages: list[ModelMessage] | None = None, + config_dir: Path | None = None, ) -> list[ModelMessage]: status = Status('[dim]Working on it…[/dim]', console=console) @@ -278,7 +312,10 @@ async def ask_agent( result = await agent.run(prompt, message_history=messages, deps=deps) content = str(result.output) console.print(Markdown(content, code_theme=code_theme)) - return result.all_messages() + result_messages = result.all_messages() + store_last_conversation(result_messages, config_dir) + + return result_messages with status, ExitStack() as stack: async with agent.iter(prompt, message_history=messages, deps=deps) as agent_run: @@ -293,7 +330,10 @@ async def ask_agent( live.update(Markdown(str(content), code_theme=code_theme)) assert agent_run.result is not None - return agent_run.result.all_messages() + result_messages = agent_run.result.all_messages() + store_last_conversation(result_messages, config_dir) + + return result_messages class CustomAutoSuggest(AutoSuggestFromHistory): From 12780903e9dcb483d09c33b435304e94c9304679 Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Sat, 19 Jul 2025 13:13:20 +0200 Subject: [PATCH 02/13] Fix tests --- tests/test_cli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index ca85564d2d..833b2d2086 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -228,21 +228,21 @@ def test_code_theme_unset(mocker: MockerFixture, env: TestEnv): env.set('OPENAI_API_KEY', 'test') mock_run_chat = mocker.patch('pydantic_ai._cli.run_chat') cli([]) - mock_run_chat.assert_awaited_once_with(True, IsInstance(Agent), IsInstance(Console), 'monokai', 'pai') + mock_run_chat.assert_awaited_once_with(True, IsInstance(Agent), IsInstance(Console), 'monokai', 'pai', history=None) def test_code_theme_light(mocker: MockerFixture, env: TestEnv): env.set('OPENAI_API_KEY', 'test') mock_run_chat = mocker.patch('pydantic_ai._cli.run_chat') cli(['--code-theme=light']) - mock_run_chat.assert_awaited_once_with(True, IsInstance(Agent), IsInstance(Console), 'default', 'pai') + mock_run_chat.assert_awaited_once_with(True, IsInstance(Agent), IsInstance(Console), 'default', 'pai', history=None) def test_code_theme_dark(mocker: MockerFixture, env: TestEnv): env.set('OPENAI_API_KEY', 'test') mock_run_chat = mocker.patch('pydantic_ai._cli.run_chat') cli(['--code-theme=dark']) - mock_run_chat.assert_awaited_once_with(True, IsInstance(Agent), IsInstance(Console), 'monokai', 'pai') + mock_run_chat.assert_awaited_once_with(True, IsInstance(Agent), IsInstance(Console), 'monokai', 'pai', history=None) def test_agent_to_cli_sync(mocker: MockerFixture, env: TestEnv): From 18ff14648ae1b7a264bc135c26866bb91f7886a4 Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Sun, 20 Jul 2025 21:34:28 +0200 Subject: [PATCH 03/13] Add tests --- tests/test_cli.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 833b2d2086..7cb0946bfc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ import sys import types from io import StringIO +from pathlib import Path from typing import Any, Callable import pytest @@ -22,7 +23,7 @@ from prompt_toolkit.output import DummyOutput from prompt_toolkit.shortcuts import PromptSession - from pydantic_ai._cli import cli, cli_agent, handle_slash_command + from pydantic_ai._cli import LAST_CONVERSATION_FILENAME, PYDANTIC_AI_HOME, cli, cli_agent, handle_slash_command from pydantic_ai.models.openai import OpenAIModel pytestmark = pytest.mark.skipif(not imports_successful(), reason='install cli extras to run cli tests') @@ -56,6 +57,16 @@ def _create_test_module(**namespace: Any) -> None: del sys.modules['test_module'] +@pytest.fixture +def emtpy_last_conversation_path(): + path = PYDANTIC_AI_HOME / LAST_CONVERSATION_FILENAME + + if path.exists(): + path.unlink() + + return path + + def test_agent_flag( capfd: CaptureFixture[str], mocker: MockerFixture, @@ -163,6 +174,51 @@ def test_cli_prompt(capfd: CaptureFixture[str], env: TestEnv): assert capfd.readouterr().out.splitlines() == snapshot([IsStr(), '# result', '', 'py', 'x = 1', '/py']) +@pytest.mark.parametrize('args', [['hello', '-c'], ['hello', '--continue']]) +def test_cli_continue_last_conversation( + args: list[str], + capfd: CaptureFixture[str], + env: TestEnv, + emtpy_last_conversation_path: Path, +): + env.set('OPENAI_API_KEY', 'test') + with cli_agent.override(model=TestModel(custom_output_text='# world')): + assert cli(args) == 0 + assert capfd.readouterr().out.splitlines() == snapshot([IsStr(), '# world']) + assert emtpy_last_conversation_path.exists() + content = emtpy_last_conversation_path.read_text() + assert content + + assert cli(args) == 0 + assert capfd.readouterr().out.splitlines() == snapshot([IsStr(), '# world']) + assert emtpy_last_conversation_path.exists() + # verity that new content is appended to the file + assert len(emtpy_last_conversation_path.read_text()) > len(content) + + +@pytest.mark.parametrize('args', [['hello', '-c'], ['hello', '--continue']]) +def test_cli_continue_last_conversation_corrupted_file( + args: list[str], + capfd: CaptureFixture[str], + env: TestEnv, + emtpy_last_conversation_path: Path, +): + env.set('OPENAI_API_KEY', 'test') + emtpy_last_conversation_path.write_text('not a valid json') + with cli_agent.override(model=TestModel(custom_output_text='# world')): + assert cli(args) == 0 + assert capfd.readouterr().out.splitlines() == snapshot( + [ + IsStr(), + 'Error loading last conversation, it is corrupted or invalid. Starting a new ', + 'conversation.', + '# world', + ] + ) + assert emtpy_last_conversation_path.exists() + assert emtpy_last_conversation_path.read_text() + + def test_chat(capfd: CaptureFixture[str], mocker: MockerFixture, env: TestEnv): env.set('OPENAI_API_KEY', 'test') with create_pipe_input() as inp: From f37042a05d3eff957ac57a2d4667de579cb26d2c Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Sun, 20 Jul 2025 21:56:44 +0200 Subject: [PATCH 04/13] Fix corrupted tests --- pydantic_ai_slim/pydantic_ai/_cli.py | 2 +- tests/test_cli.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/_cli.py b/pydantic_ai_slim/pydantic_ai/_cli.py index 6ba0dbf06f..3f8b17cdf7 100644 --- a/pydantic_ai_slim/pydantic_ai/_cli.py +++ b/pydantic_ai_slim/pydantic_ai/_cli.py @@ -218,7 +218,7 @@ def cli( # noqa: C901 history = load_last_conversation() if args.continue_ else None except ValidationError: console.print( - '[red]Error loading last conversation, it is corrupted or invalid. Starting a new conversation.[/red]' + '[red]Error loading last conversation, it is corrupted or invalid.\nStarting a new conversation.[/red]' ) history = None diff --git a/tests/test_cli.py b/tests/test_cli.py index 7cb0946bfc..1093a5b78f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -210,8 +210,8 @@ def test_cli_continue_last_conversation_corrupted_file( assert capfd.readouterr().out.splitlines() == snapshot( [ IsStr(), - 'Error loading last conversation, it is corrupted or invalid. Starting a new ', - 'conversation.', + 'Error loading last conversation, it is corrupted or invalid.', + 'Starting a new conversation.', '# world', ] ) From b4fa954f56e4b4569825a10c7dc2d2243aaa27f6 Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Sun, 20 Jul 2025 22:22:03 +0200 Subject: [PATCH 05/13] try to fix coverage --- tests/test_cli.py | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 1093a5b78f..78e225b7a3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -58,13 +58,16 @@ def _create_test_module(**namespace: Any) -> None: @pytest.fixture -def emtpy_last_conversation_path(): +def create_last_conversation_file(): path = PYDANTIC_AI_HOME / LAST_CONVERSATION_FILENAME - if path.exists(): - path.unlink() + def _factory(empty: bool = True) -> Path: + if empty and path.exists(): + path.unlink() - return path + return path + + return _factory def test_agent_flag( @@ -179,21 +182,23 @@ def test_cli_continue_last_conversation( args: list[str], capfd: CaptureFixture[str], env: TestEnv, - emtpy_last_conversation_path: Path, + create_last_conversation_file: Callable[..., Path], ): + last_conversation_path = create_last_conversation_file() + env.set('OPENAI_API_KEY', 'test') with cli_agent.override(model=TestModel(custom_output_text='# world')): assert cli(args) == 0 assert capfd.readouterr().out.splitlines() == snapshot([IsStr(), '# world']) - assert emtpy_last_conversation_path.exists() - content = emtpy_last_conversation_path.read_text() + assert last_conversation_path.exists() + content = last_conversation_path.read_text() assert content assert cli(args) == 0 assert capfd.readouterr().out.splitlines() == snapshot([IsStr(), '# world']) - assert emtpy_last_conversation_path.exists() + assert last_conversation_path.exists() # verity that new content is appended to the file - assert len(emtpy_last_conversation_path.read_text()) > len(content) + assert len(last_conversation_path.read_text()) > len(content) @pytest.mark.parametrize('args', [['hello', '-c'], ['hello', '--continue']]) @@ -201,10 +206,12 @@ def test_cli_continue_last_conversation_corrupted_file( args: list[str], capfd: CaptureFixture[str], env: TestEnv, - emtpy_last_conversation_path: Path, + create_last_conversation_file: Callable[..., Path], ): + last_conversation_path = create_last_conversation_file() + env.set('OPENAI_API_KEY', 'test') - emtpy_last_conversation_path.write_text('not a valid json') + last_conversation_path.write_text('not a valid json') with cli_agent.override(model=TestModel(custom_output_text='# world')): assert cli(args) == 0 assert capfd.readouterr().out.splitlines() == snapshot( @@ -215,8 +222,8 @@ def test_cli_continue_last_conversation_corrupted_file( '# world', ] ) - assert emtpy_last_conversation_path.exists() - assert emtpy_last_conversation_path.read_text() + assert last_conversation_path.exists() + assert last_conversation_path.read_text() def test_chat(capfd: CaptureFixture[str], mocker: MockerFixture, env: TestEnv): From cf3b6915f4127542e5e7d9324a01ba3632ff745f Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Sun, 20 Jul 2025 22:39:47 +0200 Subject: [PATCH 06/13] Revert "try to fix coverage" This reverts commit 909cabd5ff46b772e906d0d5d45e4dfaded50e97. --- tests/test_cli.py | 33 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 78e225b7a3..1093a5b78f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -58,16 +58,13 @@ def _create_test_module(**namespace: Any) -> None: @pytest.fixture -def create_last_conversation_file(): +def emtpy_last_conversation_path(): path = PYDANTIC_AI_HOME / LAST_CONVERSATION_FILENAME - def _factory(empty: bool = True) -> Path: - if empty and path.exists(): - path.unlink() + if path.exists(): + path.unlink() - return path - - return _factory + return path def test_agent_flag( @@ -182,23 +179,21 @@ def test_cli_continue_last_conversation( args: list[str], capfd: CaptureFixture[str], env: TestEnv, - create_last_conversation_file: Callable[..., Path], + emtpy_last_conversation_path: Path, ): - last_conversation_path = create_last_conversation_file() - env.set('OPENAI_API_KEY', 'test') with cli_agent.override(model=TestModel(custom_output_text='# world')): assert cli(args) == 0 assert capfd.readouterr().out.splitlines() == snapshot([IsStr(), '# world']) - assert last_conversation_path.exists() - content = last_conversation_path.read_text() + assert emtpy_last_conversation_path.exists() + content = emtpy_last_conversation_path.read_text() assert content assert cli(args) == 0 assert capfd.readouterr().out.splitlines() == snapshot([IsStr(), '# world']) - assert last_conversation_path.exists() + assert emtpy_last_conversation_path.exists() # verity that new content is appended to the file - assert len(last_conversation_path.read_text()) > len(content) + assert len(emtpy_last_conversation_path.read_text()) > len(content) @pytest.mark.parametrize('args', [['hello', '-c'], ['hello', '--continue']]) @@ -206,12 +201,10 @@ def test_cli_continue_last_conversation_corrupted_file( args: list[str], capfd: CaptureFixture[str], env: TestEnv, - create_last_conversation_file: Callable[..., Path], + emtpy_last_conversation_path: Path, ): - last_conversation_path = create_last_conversation_file() - env.set('OPENAI_API_KEY', 'test') - last_conversation_path.write_text('not a valid json') + emtpy_last_conversation_path.write_text('not a valid json') with cli_agent.override(model=TestModel(custom_output_text='# world')): assert cli(args) == 0 assert capfd.readouterr().out.splitlines() == snapshot( @@ -222,8 +215,8 @@ def test_cli_continue_last_conversation_corrupted_file( '# world', ] ) - assert last_conversation_path.exists() - assert last_conversation_path.read_text() + assert emtpy_last_conversation_path.exists() + assert emtpy_last_conversation_path.read_text() def test_chat(capfd: CaptureFixture[str], mocker: MockerFixture, env: TestEnv): From c680761c1f55d1d26b722ffe6aba90e277767cc8 Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Mon, 3 Nov 2025 22:56:06 +0100 Subject: [PATCH 07/13] Use message_history arg for run_chat; fix typos in tests --- pydantic_ai_slim/pydantic_ai/_cli.py | 2 +- tests/test_cli.py | 49 ++++++++++++++++++++-------- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/pydantic_ai_slim/pydantic_ai/_cli.py b/pydantic_ai_slim/pydantic_ai/_cli.py index 58ec717b6b..0fd38d4acc 100644 --- a/pydantic_ai_slim/pydantic_ai/_cli.py +++ b/pydantic_ai_slim/pydantic_ai/_cli.py @@ -232,7 +232,7 @@ def cli( # noqa: C901 return 0 try: - return asyncio.run(run_chat(stream, agent, console, code_theme, prog_name, history=history)) + return asyncio.run(run_chat(stream, agent, console, code_theme, prog_name, message_history=history)) except KeyboardInterrupt: # pragma: no cover return 0 diff --git a/tests/test_cli.py b/tests/test_cli.py index 92042e5eea..130d2ec0fc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -58,7 +58,7 @@ def _create_test_module(**namespace: Any) -> None: @pytest.fixture -def emtpy_last_conversation_path(): +def empty_last_conversation_path(): path = PYDANTIC_AI_HOME / LAST_CONVERSATION_FILENAME if path.exists(): @@ -180,21 +180,21 @@ def test_cli_continue_last_conversation( args: list[str], capfd: CaptureFixture[str], env: TestEnv, - emtpy_last_conversation_path: Path, + empty_last_conversation_path: Path, ): env.set('OPENAI_API_KEY', 'test') with cli_agent.override(model=TestModel(custom_output_text='# world')): assert cli(args) == 0 assert capfd.readouterr().out.splitlines() == snapshot([IsStr(), '# world']) - assert emtpy_last_conversation_path.exists() - content = emtpy_last_conversation_path.read_text() + assert empty_last_conversation_path.exists() + content = empty_last_conversation_path.read_text() assert content assert cli(args) == 0 assert capfd.readouterr().out.splitlines() == snapshot([IsStr(), '# world']) - assert emtpy_last_conversation_path.exists() - # verity that new content is appended to the file - assert len(emtpy_last_conversation_path.read_text()) > len(content) + assert empty_last_conversation_path.exists() + # verify that new content is appended to the file + assert len(empty_last_conversation_path.read_text()) > len(content) @pytest.mark.parametrize('args', [['hello', '-c'], ['hello', '--continue']]) @@ -202,10 +202,10 @@ def test_cli_continue_last_conversation_corrupted_file( args: list[str], capfd: CaptureFixture[str], env: TestEnv, - emtpy_last_conversation_path: Path, + empty_last_conversation_path: Path, ): env.set('OPENAI_API_KEY', 'test') - emtpy_last_conversation_path.write_text('not a valid json') + empty_last_conversation_path.write_text('not a valid json') with cli_agent.override(model=TestModel(custom_output_text='# world')): assert cli(args) == 0 assert capfd.readouterr().out.splitlines() == snapshot( @@ -216,8 +216,8 @@ def test_cli_continue_last_conversation_corrupted_file( '# world', ] ) - assert emtpy_last_conversation_path.exists() - assert emtpy_last_conversation_path.read_text() + assert empty_last_conversation_path.exists() + assert empty_last_conversation_path.read_text() def test_chat(capfd: CaptureFixture[str], mocker: MockerFixture, env: TestEnv): @@ -320,21 +320,42 @@ def test_code_theme_unset(mocker: MockerFixture, env: TestEnv): env.set('OPENAI_API_KEY', 'test') mock_run_chat = mocker.patch('pydantic_ai._cli.run_chat') cli([]) - mock_run_chat.assert_awaited_once_with(True, IsInstance(Agent), IsInstance(Console), 'monokai', 'pai', history=None) + mock_run_chat.assert_awaited_once_with( + True, + IsInstance(Agent), + IsInstance(Console), + 'monokai', + 'pai', + message_history=None, + ) def test_code_theme_light(mocker: MockerFixture, env: TestEnv): env.set('OPENAI_API_KEY', 'test') mock_run_chat = mocker.patch('pydantic_ai._cli.run_chat') cli(['--code-theme=light']) - mock_run_chat.assert_awaited_once_with(True, IsInstance(Agent), IsInstance(Console), 'default', 'pai', history=None) + mock_run_chat.assert_awaited_once_with( + True, + IsInstance(Agent), + IsInstance(Console), + 'default', + 'pai', + message_history=None, + ) def test_code_theme_dark(mocker: MockerFixture, env: TestEnv): env.set('OPENAI_API_KEY', 'test') mock_run_chat = mocker.patch('pydantic_ai._cli.run_chat') cli(['--code-theme=dark']) - mock_run_chat.assert_awaited_once_with(True, IsInstance(Agent), IsInstance(Console), 'monokai', 'pai', history=None) + mock_run_chat.assert_awaited_once_with( + True, + IsInstance(Agent), + IsInstance(Console), + 'monokai', + 'pai', + message_history=None, + ) def test_agent_to_cli_sync(mocker: MockerFixture, env: TestEnv): From 526069f3072f0652b55f35a0d961d33d68877c71 Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Mon, 3 Nov 2025 22:57:46 +0100 Subject: [PATCH 08/13] Use read_bytes() to load conversation JSON --- pydantic_ai_slim/pydantic_ai/_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pydantic_ai_slim/pydantic_ai/_cli.py b/pydantic_ai_slim/pydantic_ai/_cli.py index 0fd38d4acc..79201ec211 100644 --- a/pydantic_ai_slim/pydantic_ai/_cli.py +++ b/pydantic_ai_slim/pydantic_ai/_cli.py @@ -249,7 +249,7 @@ def load_last_conversation(config_dir: Path | None = None) -> list[ModelMessage] if not last_conversation_path.exists(): return None - return ModelMessagesTypeAdapter.validate_json(last_conversation_path.read_text()) + return ModelMessagesTypeAdapter.validate_json(last_conversation_path.read_bytes()) async def run_chat( From a08b5edac335b0610d9c69a41d655e88e8ed48db Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Mon, 3 Nov 2025 23:38:19 +0100 Subject: [PATCH 09/13] Patch PYDANTIC_AI_HOME with tmp_path in test fixture --- tests/test_cli.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 130d2ec0fc..1fa23a6cd7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -58,8 +58,9 @@ def _create_test_module(**namespace: Any) -> None: @pytest.fixture -def empty_last_conversation_path(): - path = PYDANTIC_AI_HOME / LAST_CONVERSATION_FILENAME +def empty_last_conversation_path(tmp_path, mocker): + path = tmp_path / LAST_CONVERSATION_FILENAME + mocker.patch('pydantic_ai._cli.PYDANTIC_AI_HOME', tmp_path) if path.exists(): path.unlink() From 8ae1e62967dce39f2f34adcbfa6687105729b8ab Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Mon, 3 Nov 2025 23:40:25 +0100 Subject: [PATCH 10/13] Remove unused PYDANTIC_AI_HOME import from tests --- tests/test_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 1fa23a6cd7..06b4011bcb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -23,7 +23,7 @@ from prompt_toolkit.output import DummyOutput from prompt_toolkit.shortcuts import PromptSession - from pydantic_ai._cli import LAST_CONVERSATION_FILENAME, PYDANTIC_AI_HOME, cli, cli_agent, handle_slash_command + from pydantic_ai._cli import LAST_CONVERSATION_FILENAME, cli, cli_agent, handle_slash_command from pydantic_ai.models.openai import OpenAIChatModel pytestmark = pytest.mark.skipif(not imports_successful(), reason='install cli extras to run cli tests') From 1e40988ba96b8f4848a1a99d4d241ea6ce18d260 Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Mon, 3 Nov 2025 23:44:51 +0100 Subject: [PATCH 11/13] Add type annotations to empty_last_conversation_path fixture --- tests/test_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 06b4011bcb..a508f83c55 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -58,7 +58,7 @@ def _create_test_module(**namespace: Any) -> None: @pytest.fixture -def empty_last_conversation_path(tmp_path, mocker): +def empty_last_conversation_path(tmp_path: Path, mocker: MockerFixture) -> Path: path = tmp_path / LAST_CONVERSATION_FILENAME mocker.patch('pydantic_ai._cli.PYDANTIC_AI_HOME', tmp_path) From 6dbe1449f549d5169b5777b14a106814032d2f96 Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Tue, 4 Nov 2025 18:07:54 +0100 Subject: [PATCH 12/13] tests: use Path.unlink(missing_ok=True) to remove file safely --- tests/test_cli.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index a508f83c55..7fc393873d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -61,9 +61,7 @@ def _create_test_module(**namespace: Any) -> None: def empty_last_conversation_path(tmp_path: Path, mocker: MockerFixture) -> Path: path = tmp_path / LAST_CONVERSATION_FILENAME mocker.patch('pydantic_ai._cli.PYDANTIC_AI_HOME', tmp_path) - - if path.exists(): - path.unlink() + path.unlink(missing_ok=True) return path From 6db9293fe71dcf50ebfa0a860917f80975a36e54 Mon Sep 17 00:00:00 2001 From: Yurii Karabas <1998uriyyo@gmail.com> Date: Tue, 4 Nov 2025 20:32:53 +0100 Subject: [PATCH 13/13] Support custom conversation load/store paths and optional --continue --- clai/README.md | 6 ++- pydantic_ai_slim/pydantic_ai/_cli.py | 81 ++++++++++++++++------------ 2 files changed, 52 insertions(+), 35 deletions(-) diff --git a/clai/README.md b/clai/README.md index 82dcf5847d..4f7ebc438f 100644 --- a/clai/README.md +++ b/clai/README.md @@ -54,7 +54,7 @@ Either way, running `clai` will start an interactive session where you can chat ## Help ``` -usage: clai [-h] [-m [MODEL]] [-a AGENT] [-l] [-t [CODE_THEME]] [-c] [--no-stream] [--version] [prompt] +usage: clai [-h] [-m [MODEL]] [-a AGENT] [-l] [-t [CODE_THEME]] [-c [CONTINUE_]] [--store STORE] [--no-stream] [--version] [prompt] Pydantic AI CLI v... @@ -76,7 +76,9 @@ options: -l, --list-models List all available models and exit -t [CODE_THEME], --code-theme [CODE_THEME] Which colors to use for code, can be "dark", "light" or any theme from pygments.org/styles/. Defaults to "dark" which works well on dark terminals. - -c, --continue Continue last conversation, if any, instead of starting a new one. + -c [CONTINUE_], --continue [CONTINUE_] + Continue last conversation, if any, instead of starting a new one. + --store STORE Store the last conversation to the specified path instead of the default location. --no-stream Disable streaming from the model --version Show version and exit ``` diff --git a/pydantic_ai_slim/pydantic_ai/_cli.py b/pydantic_ai_slim/pydantic_ai/_cli.py index 79201ec211..3cc8d8b8fd 100644 --- a/pydantic_ai_slim/pydantic_ai/_cli.py +++ b/pydantic_ai_slim/pydantic_ai/_cli.py @@ -153,10 +153,17 @@ def cli( # noqa: C901 parser.add_argument( '-c', '--continue', + nargs='?', dest='continue_', - action='store_true', + const=str(PYDANTIC_AI_HOME / LAST_CONVERSATION_FILENAME), + default=None, help='Continue last conversation, if any, instead of starting a new one.', ) + parser.add_argument( + '--store', + help='Store the last conversation to the specified path instead of the default location.', + default=None, + ) parser.add_argument('--no-stream', action='store_true', help='Disable streaming from the model') parser.add_argument('--version', action='store_true', help='Show version and exit') @@ -216,40 +223,47 @@ def cli( # noqa: C901 else: code_theme = args.code_theme # pragma: no cover + load_path: Path | None = None + if args.continue_: + load_path = Path(args.continue_) + + store_path: Path = PYDANTIC_AI_HOME / LAST_CONVERSATION_FILENAME + if args.store: + store_path = Path(args.store) + try: - history = load_last_conversation() if args.continue_ else None + history = load_conversation(load_path) if load_path else None except ValidationError: console.print( - '[red]Error loading last conversation, it is corrupted or invalid.\nStarting a new conversation.[/red]' + '[red]Error loading conversation, it is corrupted or invalid.\nStarting a new conversation.[/red]' ) history = None if prompt := cast(str, args.prompt): try: - asyncio.run(ask_agent(agent, prompt, stream, console, code_theme, messages=history)) + asyncio.run(ask_agent(agent, prompt, stream, console, code_theme, messages=history, store_path=store_path)) except KeyboardInterrupt: pass return 0 try: - return asyncio.run(run_chat(stream, agent, console, code_theme, prog_name, message_history=history)) + return asyncio.run( + run_chat(stream, agent, console, code_theme, prog_name, message_history=history, store_path=store_path) + ) except KeyboardInterrupt: # pragma: no cover return 0 -def store_last_conversation(messages: list[ModelMessage], config_dir: Path | None = None) -> None: - last_conversation_path = (config_dir or PYDANTIC_AI_HOME) / LAST_CONVERSATION_FILENAME - last_conversation_path.parent.mkdir(parents=True, exist_ok=True) - last_conversation_path.write_bytes(ModelMessagesTypeAdapter.dump_json(messages)) - +def store_conversation(messages: list[ModelMessage], store_path: Path) -> None: + store_path.parent.mkdir(parents=True, exist_ok=True) + store_path.write_bytes(ModelMessagesTypeAdapter.dump_json(messages)) -def load_last_conversation(config_dir: Path | None = None) -> list[ModelMessage] | None: - last_conversation_path = (config_dir or PYDANTIC_AI_HOME) / LAST_CONVERSATION_FILENAME - if not last_conversation_path.exists(): +def load_conversation(load_path: Path) -> list[ModelMessage] | None: + if not load_path.exists(): return None - return ModelMessagesTypeAdapter.validate_json(last_conversation_path.read_bytes()) + return ModelMessagesTypeAdapter.validate_json(load_path.read_bytes()) async def run_chat( @@ -261,6 +275,7 @@ async def run_chat( config_dir: Path | None = None, deps: AgentDepsT = None, message_history: Sequence[ModelMessage] | None = None, + store_path: Path | None = None, ) -> int: prompt_history_path = (config_dir or PYDANTIC_AI_HOME) / PROMPT_HISTORY_FILENAME prompt_history_path.parent.mkdir(parents=True, exist_ok=True) @@ -287,7 +302,7 @@ async def run_chat( return exit_value else: try: - messages = await ask_agent(agent, text, stream, console, code_theme, deps, messages, config_dir) + messages = await ask_agent(agent, text, stream, console, code_theme, deps, messages, store_path) except CancelledError: # pragma: no cover console.print('[dim]Interrupted[/dim]') except Exception as e: # pragma: no cover @@ -305,7 +320,7 @@ async def ask_agent( code_theme: str, deps: AgentDepsT = None, messages: Sequence[ModelMessage] | None = None, - config_dir: Path | None = None, + store_path: Path | None = None, ) -> list[ModelMessage]: status = Status('[dim]Working on it…[/dim]', console=console) @@ -314,28 +329,28 @@ async def ask_agent( result = await agent.run(prompt, message_history=messages, deps=deps) content = str(result.output) console.print(Markdown(content, code_theme=code_theme)) - result_messages = result.all_messages() - store_last_conversation(result_messages, config_dir) + else: + with status, ExitStack() as stack: + async with agent.iter(prompt, message_history=messages, deps=deps) as agent_run: + live = Live('', refresh_per_second=15, console=console, vertical_overflow='ellipsis') + async for node in agent_run: + if Agent.is_model_request_node(node): + async with node.stream(agent_run.ctx) as handle_stream: + status.stop() # stopping multiple times is idempotent + stack.enter_context(live) # entering multiple times is idempotent - return result_messages + async for content in handle_stream.stream_output(debounce_by=None): + live.update(Markdown(str(content), code_theme=code_theme)) - with status, ExitStack() as stack: - async with agent.iter(prompt, message_history=messages, deps=deps) as agent_run: - live = Live('', refresh_per_second=15, console=console, vertical_overflow='ellipsis') - async for node in agent_run: - if Agent.is_model_request_node(node): - async with node.stream(agent_run.ctx) as handle_stream: - status.stop() # stopping multiple times is idempotent - stack.enter_context(live) # entering multiple times is idempotent + assert agent_run.result is not None + result = agent_run.result - async for content in handle_stream.stream_output(debounce_by=None): - live.update(Markdown(str(content), code_theme=code_theme)) + result_messages = result.all_messages() - assert agent_run.result is not None - result_messages = agent_run.result.all_messages() - store_last_conversation(result_messages, config_dir) + if store_path: + store_conversation(result_messages, store_path) - return result_messages + return result_messages class CustomAutoSuggest(AutoSuggestFromHistory):