multacd 2.0.0b7__tar.gz → 2.0.0b9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {multacd-2.0.0b7 → multacd-2.0.0b9}/.github/workflows/release.yml +1 -1
- {multacd-2.0.0b7 → multacd-2.0.0b9}/CHANGELOG.md +20 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/PKG-INFO +1 -1
- multacd-2.0.0b9/core/agent_events.py +86 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/core/agent_loop.py +30 -56
- multacd-2.0.0b9/core/research/bus.py +66 -0
- multacd-2.0.0b9/core/session_state.py +127 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/core/version.py +1 -1
- {multacd-2.0.0b7 → multacd-2.0.0b9}/pyproject.toml +1 -1
- {multacd-2.0.0b7 → multacd-2.0.0b9}/scripts/install.sh +1 -1
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/test_selftests.py +10 -0
- multacd-2.0.0b9/tests/unit/test_agent_controller.py +118 -0
- multacd-2.0.0b9/tests/unit/test_research_bus.py +69 -0
- multacd-2.0.0b9/tests/unit/test_session_state.py +66 -0
- multacd-2.0.0b9/tests/unit/test_splash.py +20 -0
- multacd-2.0.0b9/tests/unit/test_tui_r1.py +88 -0
- multacd-2.0.0b9/tests/unit/test_tui_r2.py +24 -0
- multacd-2.0.0b9/tests/unit/test_tui_r3.py +53 -0
- multacd-2.0.0b9/tests/unit/test_tui_r4.py +39 -0
- multacd-2.0.0b9/tests/unit/test_tui_r5.py +35 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/research/quick_research.py +9 -2
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tui/app.py +4 -0
- multacd-2.0.0b9/tui/controllers/agent_controller.py +184 -0
- multacd-2.0.0b9/tui/layout.py +92 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tui/screens/main_screen.py +341 -140
- multacd-2.0.0b9/tui/widgets/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tui/widgets/chat_panel.py +96 -18
- multacd-2.0.0b9/tui/widgets/context_sidebar.py +67 -0
- multacd-2.0.0b9/tui/widgets/footer_bar.py +86 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tui/widgets/info_panel.py +31 -8
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tui/widgets/input_bar.py +28 -0
- multacd-2.0.0b9/tui/widgets/provider_selector.py +151 -0
- multacd-2.0.0b9/tui/widgets/session_bar.py +45 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tui/widgets/slash_palette.py +112 -19
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tui/widgets/status_bar.py +31 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tui/widgets/thinking_bar.py +43 -0
- multacd-2.0.0b7/core/research/bus.py +0 -41
- {multacd-2.0.0b7 → multacd-2.0.0b9}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/.github/pull_request_template.md +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/.github/workflows/test.yml +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/.gitignore +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/CONTRIBUTING.md +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/DESIGN.md +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/LICENSE +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/README.md +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/briefing/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/briefing/generator.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/briefing/privacy.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/briefing/sources/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/briefing/sources/git_source.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/briefing/sources/news_source.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/briefing/sources/todo_source.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/core/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/core/clipboard.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/core/codebase.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/core/config.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/core/llm_client.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/core/mode_manager.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/core/permissions.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/core/plugin_loader.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/core/prompt_composer.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/core/providers/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/core/providers/__main__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/core/providers/anthropic.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/core/providers/openai_compat.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/core/research/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/core/research/orchestrator.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/core/research/query_generator.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/core/updater.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/daemon/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/daemon/ipc.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/daemon/process.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/docs/configuration.md +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/docs/index.md +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/docs/install.md +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/docs/mkdocs.yml +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/docs/modes/coding.md +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/docs/modes/personal.md +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/docs/modes/research.md +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/docs/plugins.md +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/docs/quickstart.md +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/main.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/memory/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/memory/context.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/memory/store.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/multacd.spec +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/plan/PLAN-phase1.md +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/plan/PLAN-phase2.md +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/plugins/README.md +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/plugins/example_plugin.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/requirements.txt +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/roadmap/ROADMAP.md +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/scheduler/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/scheduler/engine.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/scheduler/jobs/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/scheduler/jobs/briefing_job.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/scheduler/jobs/research_job.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/scripts/install.ps1 +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/search_providers/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/search_providers/__main__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/search_providers/base.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/search_providers/brave.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/search_providers/duckduckgo.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/search_providers/exa.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/search_providers/serpapi.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/search_providers/tavily.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/soul.md +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/conftest.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/integration/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/integration/test_agent_loop.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/unit/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/unit/test_binary.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/unit/test_config.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/unit/test_docs.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/unit/test_entrypoint.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/unit/test_github.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/unit/test_installer.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/unit/test_permissions.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/unit/test_plugin_loader.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/unit/test_privacy_filter.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/unit/test_provider_keys.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/unit/test_search_providers/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/unit/test_search_providers/test_fallback.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/unit/test_search_providers/test_tavily.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/unit/test_tools/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/unit/test_tools/test_bash.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/unit/test_tools/test_read_file.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/unit/test_tools/test_web_scrape.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/unit/test_tools/test_write_file.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tests/unit/test_updater.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tg/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tg/access_control.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tg/agent.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tg/bot.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tg/file_handler.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tg/formatter.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tg/handlers.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/agent/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/agent/ask.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/agent/skill.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/agent/task.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/agent/todo_write.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/code/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/code/lint_python.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/code/run_python.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/code/run_tests.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/codebase/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/codebase/scan_codebase.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/common.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/filesystem/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/filesystem/apply_patch.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/filesystem/delete_file.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/filesystem/edit_file.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/filesystem/glob.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/filesystem/grep.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/filesystem/list_dir.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/filesystem/move_file.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/filesystem/multi_edit.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/filesystem/read_file.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/filesystem/read_many_files.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/filesystem/write_file.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/git/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/git/_helper.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/git/git_add.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/git/git_branch.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/git/git_checkout.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/git/git_commit.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/git/git_diff.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/git/git_log.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/git/git_merge.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/git/git_pull.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/git/git_push.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/git/git_status.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/memory/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/memory/forget.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/memory/recall.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/memory/remember.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/personal/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/personal/cancel_job.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/personal/daemon_status.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/personal/generate_briefing.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/personal/get_jobs.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/personal/schedule_job.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/personal/send_telegram.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/personal/user_manager.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/registry.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/research/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/research/deep_research.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/research/export_research.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/research/web_scrape.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/research/web_search.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/shell/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/shell/bash.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/streaming.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/web/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tools/web/web_fetch.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tui/__init__.py +0 -0
- {multacd-2.0.0b7/tui/screens → multacd-2.0.0b9/tui/controllers}/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tui/icons.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tui/markup_safe.py +0 -0
- {multacd-2.0.0b7/tui/widgets → multacd-2.0.0b9/tui/screens}/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tui/screens/setup_wizard.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tui/themes/__init__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tui/themes/__main__.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tui/tokens.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tui/widgets/confirm_dialog.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tui/widgets/diff_viewer.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tui/widgets/file_tree.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tui/widgets/model_selector.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tui/widgets/permission_popup.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tui/widgets/sources_panel.py +0 -0
- {multacd-2.0.0b7 → multacd-2.0.0b9}/tui/widgets/tool_activity.py +0 -0
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
Format: Keep a Changelog. Versi: Semantic Versioning.
|
|
4
4
|
|
|
5
|
+
## [2.0.0b9] - 2026-09-13
|
|
6
|
+
|
|
7
|
+
Beta v2 kesembilan (redesign TUI, tanpa ubah agent): shell layout
|
|
8
|
+
(session bar + sidebar responsif + footer, TUI-R1), gaya pesan chat
|
|
9
|
+
(aksen user + meta jawaban, TUI-R2), palette overlay + grup (TUI-R3),
|
|
10
|
+
provider selector popup + model overlay (TUI-R4), snapshot konteks
|
|
11
|
+
jujur in/out + status (TUI-R5), input adaptif layar pendek (TUI-R6).
|
|
12
|
+
Fix: short_workdir Windows (#41). Catatan: #44 (skills), #47 (limit/MCP).
|
|
13
|
+
|
|
14
|
+
Install: `pip install "multacd==2.0.0b9"` (tanpa --pre).
|
|
15
|
+
|
|
16
|
+
## [2.0.0b8] - 2026-09-13
|
|
17
|
+
|
|
18
|
+
Beta v2 kedelapan (refactor arsitektur, tanpa ubah fitur): kontrak event
|
|
19
|
+
`core/agent_events` (R2), SessionState + reducer keluar widget (R3),
|
|
20
|
+
AgentController + fix approve [A] lintas turn (#32, R4), widget render
|
|
21
|
+
dari state (R5), research sink task-scoped (R6), splash §10 lengkap (R7).
|
|
22
|
+
|
|
23
|
+
Install: `pip install "multacd==2.0.0b8"` (tanpa --pre).
|
|
24
|
+
|
|
5
25
|
## [2.0.0b7] - 2026-09-12
|
|
6
26
|
|
|
7
27
|
Beta v2 ketujuh (borongan): config key kosong dimaafkan (#27), limit jadi
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: multacd
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.0b9
|
|
4
4
|
Summary: Agentic TUI for coding, research, and personal automation
|
|
5
5
|
Project-URL: Homepage, https://github.com/prototypeall850-creator/multacd
|
|
6
6
|
Project-URL: Issues, https://github.com/prototypeall850-creator/multacd/issues
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Agent event contract — satu-satunya bahasa agent runtime → UI.
|
|
2
|
+
|
|
3
|
+
Dipakai `core/agent_loop.py` (yield) dan consumer (`tui`, `tg`, tests).
|
|
4
|
+
Modul ini murni dataclass: tanpa import Textual, tanpa I/O, tanpa LLM —
|
|
5
|
+
tujuannya biar boundary tidak gampang bocor saat refactor (R2).
|
|
6
|
+
|
|
7
|
+
Test cepat:
|
|
8
|
+
python -m core.agent_events
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class AgentText:
|
|
19
|
+
delta: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class AgentToolStart:
|
|
24
|
+
call_id: str
|
|
25
|
+
name: str
|
|
26
|
+
params: dict[str, Any]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class AgentToolDone:
|
|
31
|
+
call_id: str
|
|
32
|
+
name: str
|
|
33
|
+
success: bool
|
|
34
|
+
result: dict[str, Any] | None = None # payload penuh (D4: expandable view)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class AgentDone:
|
|
39
|
+
text: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class AgentUsage:
|
|
44
|
+
"""Token resmi provider untuk satu panggilan LLM (0 = tak dilapor)."""
|
|
45
|
+
|
|
46
|
+
prompt_tokens: int = 0
|
|
47
|
+
completion_tokens: int = 0
|
|
48
|
+
cost_usd: float = 0.0 # estimasi lokal (tabel harga provider)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class AgentContinue:
|
|
53
|
+
"""LLM minta lanjut padahal batas iterasi tercapai.
|
|
54
|
+
|
|
55
|
+
Bukan error — TUI tampilkan ringkasan + tombol Lanjut/Berhenti.
|
|
56
|
+
CLI fallback: auto-lanjut kalau stdin bukan TTY? tidak — default berhenti.
|
|
57
|
+
Lihat run_agent(continue_on_limit) + main_screen._run_turn.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
tool_count: int
|
|
61
|
+
limit: int
|
|
62
|
+
summary: str # ringkasan tool terakhir biar user bisa nilai
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class AgentError:
|
|
67
|
+
message: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
AgentEvent = AgentText | AgentToolStart | AgentToolDone | AgentDone | AgentUsage | AgentContinue | AgentError
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
evs: list[AgentEvent] = [
|
|
75
|
+
AgentText("halo"),
|
|
76
|
+
AgentToolStart("c1", "list_dir", {"path": "."}),
|
|
77
|
+
AgentToolDone("c1", "list_dir", True, {"success": True}),
|
|
78
|
+
AgentDone("selesai"),
|
|
79
|
+
AgentUsage(10, 5, 0.001),
|
|
80
|
+
AgentContinue(3, 3, "list_dir"),
|
|
81
|
+
AgentError("gagal"),
|
|
82
|
+
]
|
|
83
|
+
assert len(evs) == 7
|
|
84
|
+
assert isinstance(evs[0], AgentText) and evs[0].delta == "halo"
|
|
85
|
+
assert isinstance(evs[5], AgentContinue) and evs[5].limit == 3
|
|
86
|
+
print("✅ agent_events self-test OK (7 event contract)")
|
|
@@ -22,9 +22,18 @@ from __future__ import annotations
|
|
|
22
22
|
|
|
23
23
|
import asyncio
|
|
24
24
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
25
|
-
from dataclasses import dataclass
|
|
26
25
|
from typing import TYPE_CHECKING, Any
|
|
27
26
|
|
|
27
|
+
from core.agent_events import (
|
|
28
|
+
AgentContinue,
|
|
29
|
+
AgentDone,
|
|
30
|
+
AgentError,
|
|
31
|
+
AgentEvent,
|
|
32
|
+
AgentText,
|
|
33
|
+
AgentToolDone,
|
|
34
|
+
AgentToolStart,
|
|
35
|
+
AgentUsage,
|
|
36
|
+
)
|
|
28
37
|
from core.config import Config
|
|
29
38
|
from core.llm_client import LLMClient, LLMError, StreamDone, StreamText, setup_client
|
|
30
39
|
from core.permissions import PermissionChecker
|
|
@@ -54,57 +63,19 @@ AskCallback = Callable[[str], Awaitable[str]]
|
|
|
54
63
|
OutputCallback = Callable[[str, str], None]
|
|
55
64
|
|
|
56
65
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
class AgentToolDone:
|
|
71
|
-
call_id: str
|
|
72
|
-
name: str
|
|
73
|
-
success: bool
|
|
74
|
-
result: dict[str, Any] | None = None # payload penuh (D4: expandable view)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
@dataclass
|
|
78
|
-
class AgentDone:
|
|
79
|
-
text: str
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
@dataclass
|
|
83
|
-
class AgentUsage:
|
|
84
|
-
"""Token resmi provider untuk satu panggilan LLM (0 = tak dilapor)."""
|
|
85
|
-
|
|
86
|
-
prompt_tokens: int = 0
|
|
87
|
-
completion_tokens: int = 0
|
|
88
|
-
cost_usd: float = 0.0 # estimasi lokal (tabel harga provider)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
@dataclass
|
|
92
|
-
class AgentContinue:
|
|
93
|
-
"""LLM minta lanjut padahal batas iterasi tercapai.
|
|
94
|
-
|
|
95
|
-
Bukan error — TUI tampilkan ringkasan + tombol Lanjut/Berhenti.
|
|
96
|
-
CLI fallback: auto-lanjut kalau stdin bukan TTY? tidak — default berhenti.
|
|
97
|
-
Lihat run_agent(continue_on_limit) + main_screen._run_turn.
|
|
98
|
-
"""
|
|
99
|
-
|
|
100
|
-
tool_count: int
|
|
101
|
-
limit: int
|
|
102
|
-
summary: str # ringkasan tool terakhir biar user bisa nilai
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
@dataclass
|
|
106
|
-
class AgentError:
|
|
107
|
-
message: str
|
|
66
|
+
# Re-export biar import lama (`from core.agent_loop import AgentText, ...`)
|
|
67
|
+
# tetap jalan selama migrasi (R2: tanpa ubah behavior).
|
|
68
|
+
__all__ = [
|
|
69
|
+
"AgentContinue",
|
|
70
|
+
"AgentDone",
|
|
71
|
+
"AgentError",
|
|
72
|
+
"AgentEvent",
|
|
73
|
+
"AgentText",
|
|
74
|
+
"AgentToolDone",
|
|
75
|
+
"AgentToolStart",
|
|
76
|
+
"AgentUsage",
|
|
77
|
+
"run_agent",
|
|
78
|
+
]
|
|
108
79
|
|
|
109
80
|
|
|
110
81
|
def _stuck_error(tool_name: str) -> AgentError:
|
|
@@ -116,9 +87,6 @@ def _stuck_error(tool_name: str) -> AgentError:
|
|
|
116
87
|
)
|
|
117
88
|
|
|
118
89
|
|
|
119
|
-
AgentEvent = AgentText | AgentToolStart | AgentToolDone | AgentDone | AgentUsage | AgentContinue | AgentError
|
|
120
|
-
|
|
121
|
-
|
|
122
90
|
async def _stdin_confirm(tool_name: str, params: dict[str, Any]) -> str:
|
|
123
91
|
target = params.get("path") or params.get("command") or params.get("url") or ""
|
|
124
92
|
extra = ""
|
|
@@ -167,6 +135,7 @@ async def run_agent(
|
|
|
167
135
|
composer: PromptComposer | None = None,
|
|
168
136
|
output_cb: OutputCallback | None = None,
|
|
169
137
|
continue_on_limit: bool = False,
|
|
138
|
+
checker: PermissionChecker | None = None,
|
|
170
139
|
) -> AsyncIterator[AgentEvent]:
|
|
171
140
|
"""Jalankan satu turn agent. Yield AgentEvent secara real-time.
|
|
172
141
|
|
|
@@ -176,6 +145,11 @@ async def run_agent(
|
|
|
176
145
|
retry lanjutan) langsung lanjut tanpa tanya. Infinite loop nyata
|
|
177
146
|
(LLM ngulang tool sama tanpa progres) tetap berhenti via deteksi
|
|
178
147
|
stagnan di bawah.
|
|
148
|
+
|
|
149
|
+
`checker` opsional biar pemilik sesi (AgentController) bisa pakai
|
|
150
|
+
satu PermissionChecker lintas turn — approve [A] jadi beneran
|
|
151
|
+
per-sesi, bukan hilang tiap turn (issue #32). Default None =
|
|
152
|
+
checker baru per turn (perilaku lama, dipakai CLI/test).
|
|
179
153
|
"""
|
|
180
154
|
def _system_prompt() -> str:
|
|
181
155
|
return composer.compose() if composer is not None else SYSTEM_PROMPT
|
|
@@ -191,7 +165,7 @@ async def run_agent(
|
|
|
191
165
|
return
|
|
192
166
|
|
|
193
167
|
llm = llm_client or setup_client(config)
|
|
194
|
-
checker = PermissionChecker(config)
|
|
168
|
+
checker = checker or PermissionChecker(config)
|
|
195
169
|
confirm_cb = confirm or _stdin_confirm
|
|
196
170
|
ask_cb = ask_user or _stdin_ask
|
|
197
171
|
defs = get_tool_definitions()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Bus event research — jembatan orchestrator → TUI (R6: task-scoped).
|
|
2
|
+
|
|
3
|
+
Alur: tool research (quick/deep) jalan sync di worker, orchestrator-nya
|
|
4
|
+
async (kadang di thread terpisah via _run_coro). Callback on_event harus
|
|
5
|
+
sampai ke widget Textual yang hidup di app loop.
|
|
6
|
+
|
|
7
|
+
Dulu global mutable (`_sink`) — satu turn nimpa turn lain kalau dua sesi
|
|
8
|
+
jalan bareng (TUI + daemon/tg, test paralel). Sekarang ContextVar: tiap
|
|
9
|
+
task asyncio bawa sink sendiri-sendiri. Rantai propagasi dijaga dua titik:
|
|
10
|
+
|
|
11
|
+
- `asyncio.to_thread(execute_tool)` (agent_loop) = copy context otomatis.
|
|
12
|
+
- `_run_coro` (tools/research) = copy explisit (pool thread tidak warisi
|
|
13
|
+
context sendiri) — lihat quick_research._run_coro.
|
|
14
|
+
|
|
15
|
+
API (set/get) tidak berubah — caller (controller, tool wrapper) sama.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import contextvars
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
_sink: contextvars.ContextVar[
|
|
25
|
+
Callable[[dict[str, Any]], None] | None
|
|
26
|
+
] = contextvars.ContextVar("research_sink", default=None)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def set_research_sink(fn: Callable[[dict[str, Any]], None] | None) -> None:
|
|
30
|
+
"""Pasang/cabut sink buat task ini saja (None = cabut)."""
|
|
31
|
+
_sink.set(fn)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_research_sink() -> Callable[[dict[str, Any]], None] | None:
|
|
35
|
+
"""Dibaca tool wrapper research saat panggil orchestrator."""
|
|
36
|
+
return _sink.get()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
import asyncio as _asyncio
|
|
41
|
+
|
|
42
|
+
assert get_research_sink() is None
|
|
43
|
+
evs: list = []
|
|
44
|
+
set_research_sink(evs.append)
|
|
45
|
+
assert get_research_sink() is not None
|
|
46
|
+
get_research_sink()({"type": "x"})
|
|
47
|
+
assert evs == [{"type": "x"}]
|
|
48
|
+
set_research_sink(None)
|
|
49
|
+
assert get_research_sink() is None
|
|
50
|
+
|
|
51
|
+
# Task-scoped: dua task tak saling timpa.
|
|
52
|
+
async def _main() -> None:
|
|
53
|
+
async def _isi(nama: str, wadah: list) -> None:
|
|
54
|
+
set_research_sink(wadah.append)
|
|
55
|
+
await _asyncio.sleep(0.01) # kasih jeda biar interleave
|
|
56
|
+
get_research_sink()({"type": nama}) # type: ignore[operator]
|
|
57
|
+
|
|
58
|
+
a: list = []
|
|
59
|
+
b: list = []
|
|
60
|
+
await _asyncio.gather(_isi("a", a), _isi("b", b))
|
|
61
|
+
assert a == [{"type": "a"}], a
|
|
62
|
+
assert b == [{"type": "b"}], b
|
|
63
|
+
assert get_research_sink() is None # task utama tak tercemar
|
|
64
|
+
|
|
65
|
+
_asyncio.run(_main())
|
|
66
|
+
print("✅ research bus self-test OK (task-scoped)")
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Session state — satu sumber kebenaran status agent per sesi (R3).
|
|
2
|
+
|
|
3
|
+
Dulu status turn/token/cost/tool tersebar di `MainScreen`
|
|
4
|
+
(`_turn_running`, `_tools_run`, `_sess_*`). Sekarang satu object:
|
|
5
|
+
|
|
6
|
+
state = SessionState()
|
|
7
|
+
state = reduce_event(state, event) # tiap AgentEvent
|
|
8
|
+
ui.render(state) # widget baca state, bukan sebaliknya
|
|
9
|
+
|
|
10
|
+
Tanpa import Textual — headless testable. Mode/model tetap milik
|
|
11
|
+
config/mode_manager (tidak diduplikat di sini).
|
|
12
|
+
|
|
13
|
+
Test cepat:
|
|
14
|
+
python -m core.session_state
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from enum import Enum
|
|
21
|
+
|
|
22
|
+
from core.agent_events import (
|
|
23
|
+
AgentContinue,
|
|
24
|
+
AgentDone,
|
|
25
|
+
AgentError,
|
|
26
|
+
AgentEvent,
|
|
27
|
+
AgentText,
|
|
28
|
+
AgentToolDone,
|
|
29
|
+
AgentToolStart,
|
|
30
|
+
AgentUsage,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AgentStatus(str, Enum):
|
|
35
|
+
IDLE = "idle"
|
|
36
|
+
THINKING = "thinking"
|
|
37
|
+
EXECUTING_TOOL = "executing"
|
|
38
|
+
WAITING_PERMISSION = "waiting_permission"
|
|
39
|
+
WAITING_USER = "waiting_user"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class SessionState:
|
|
44
|
+
"""Status agent + metrik sesi. Satu instance per sesi TUI."""
|
|
45
|
+
|
|
46
|
+
status: AgentStatus = AgentStatus.IDLE
|
|
47
|
+
current_tool: str | None = None
|
|
48
|
+
current_call_id: str | None = None
|
|
49
|
+
tool_count: int = 0
|
|
50
|
+
prompt_tokens: int = 0
|
|
51
|
+
completion_tokens: int = 0
|
|
52
|
+
cost_usd: float = 0.0
|
|
53
|
+
# Izin [A] per sesi (#32): resolver hidup di sini nantinya (R4).
|
|
54
|
+
# Dicatat dulu biar kontraknya jelas — belum dipakai runtime.
|
|
55
|
+
session_approved: set[str] = field(default_factory=set)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def busy(self) -> bool:
|
|
59
|
+
"""True selama turn jalan (dipakai submit-guard + quit-guard)."""
|
|
60
|
+
return self.status is not AgentStatus.IDLE
|
|
61
|
+
|
|
62
|
+
def begin_turn(self) -> None:
|
|
63
|
+
self.status = AgentStatus.THINKING
|
|
64
|
+
self.current_tool = None
|
|
65
|
+
self.current_call_id = None
|
|
66
|
+
|
|
67
|
+
def end_turn(self) -> None:
|
|
68
|
+
self.status = AgentStatus.IDLE
|
|
69
|
+
self.current_tool = None
|
|
70
|
+
self.current_call_id = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def reduce_event(state: SessionState, event: AgentEvent) -> SessionState:
|
|
74
|
+
"""Lipat satu AgentEvent ke state. Return state yang sama (boleh chaining)."""
|
|
75
|
+
if isinstance(event, AgentToolStart):
|
|
76
|
+
state.status = AgentStatus.EXECUTING_TOOL
|
|
77
|
+
state.current_tool = event.name
|
|
78
|
+
state.current_call_id = event.call_id
|
|
79
|
+
elif isinstance(event, AgentToolDone):
|
|
80
|
+
state.status = AgentStatus.THINKING
|
|
81
|
+
state.current_tool = None
|
|
82
|
+
state.current_call_id = None
|
|
83
|
+
state.tool_count += 1
|
|
84
|
+
elif isinstance(event, AgentUsage):
|
|
85
|
+
state.prompt_tokens += event.prompt_tokens
|
|
86
|
+
state.completion_tokens += event.completion_tokens
|
|
87
|
+
state.cost_usd += event.cost_usd
|
|
88
|
+
elif isinstance(event, AgentContinue):
|
|
89
|
+
state.status = AgentStatus.WAITING_USER
|
|
90
|
+
state.current_tool = None
|
|
91
|
+
state.current_call_id = None
|
|
92
|
+
elif isinstance(event, (AgentDone, AgentError)):
|
|
93
|
+
state.status = AgentStatus.IDLE
|
|
94
|
+
state.current_tool = None
|
|
95
|
+
state.current_call_id = None
|
|
96
|
+
elif isinstance(event, AgentText):
|
|
97
|
+
if state.status in (AgentStatus.IDLE, AgentStatus.WAITING_USER):
|
|
98
|
+
state.status = AgentStatus.THINKING
|
|
99
|
+
return state
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
s = SessionState()
|
|
104
|
+
assert not s.busy and s.status is AgentStatus.IDLE
|
|
105
|
+
s.begin_turn()
|
|
106
|
+
assert s.busy
|
|
107
|
+
reduce_event(s, AgentText("hai"))
|
|
108
|
+
assert s.status is AgentStatus.THINKING
|
|
109
|
+
reduce_event(s, AgentToolStart("c1", "bash", {"command": "ls"}))
|
|
110
|
+
assert s.status is AgentStatus.EXECUTING_TOOL
|
|
111
|
+
assert s.current_tool == "bash" and s.current_call_id == "c1"
|
|
112
|
+
reduce_event(s, AgentUsage(10, 5, 0.001))
|
|
113
|
+
assert (s.prompt_tokens, s.completion_tokens) == (10, 5)
|
|
114
|
+
reduce_event(s, AgentToolDone("c1", "bash", True, {"success": True}))
|
|
115
|
+
assert s.tool_count == 1 and s.current_tool is None
|
|
116
|
+
assert s.status is AgentStatus.THINKING
|
|
117
|
+
reduce_event(s, AgentContinue(3, 3, "bash"))
|
|
118
|
+
assert s.status is AgentStatus.WAITING_USER and s.busy
|
|
119
|
+
s.begin_turn() # lanjut → thinking lagi
|
|
120
|
+
reduce_event(s, AgentDone("ok"))
|
|
121
|
+
assert s.status is AgentStatus.IDLE and not s.busy
|
|
122
|
+
s.begin_turn()
|
|
123
|
+
reduce_event(s, AgentError("boom"))
|
|
124
|
+
assert s.status is AgentStatus.IDLE
|
|
125
|
+
s.end_turn()
|
|
126
|
+
assert not s.busy
|
|
127
|
+
print("✅ session_state self-test OK (reducer + lifecycle)")
|
|
@@ -8,7 +8,7 @@ Saat bump versi (Step 10 → 1.0.0): ubah pyproject + FALLBACK di sini.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
-
FALLBACK = "2.0.
|
|
11
|
+
FALLBACK = "2.0.0b9" # beta v2; samakan dengan pyproject.toml
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def get_version() -> str:
|
|
@@ -78,7 +78,7 @@ termux_pip_guide() {
|
|
|
78
78
|
echo " (ringan: tanpa kompilasi Rust — dependensi wheel murni)"
|
|
79
79
|
echo ""
|
|
80
80
|
echo " 2. Install multacd:"
|
|
81
|
-
echo " pip install \"multacd==2.0.
|
|
81
|
+
echo " pip install \"multacd==2.0.0b9\""
|
|
82
82
|
echo ""
|
|
83
83
|
echo " 3. Jalankan: multacd"
|
|
84
84
|
echo ""
|
|
@@ -26,6 +26,9 @@ _ISOLATED_HOME = tempfile.mkdtemp(prefix="multacd-pytest-home-")
|
|
|
26
26
|
# Modul dengan self-test `python -m <module>` (exit 0 = lolos).
|
|
27
27
|
SELF_TEST_MODULES = [
|
|
28
28
|
"core.agent_loop",
|
|
29
|
+
"core.agent_events",
|
|
30
|
+
"core.session_state",
|
|
31
|
+
"tui.controllers.agent_controller",
|
|
29
32
|
"core.llm_client",
|
|
30
33
|
"core.permissions",
|
|
31
34
|
"core.mode_manager",
|
|
@@ -51,6 +54,13 @@ SELF_TEST_MODULES = [
|
|
|
51
54
|
"tui.themes",
|
|
52
55
|
"tui.markup_safe",
|
|
53
56
|
"tui.widgets.thinking_bar",
|
|
57
|
+
"tui.widgets.status_bar",
|
|
58
|
+
"tui.widgets.info_panel",
|
|
59
|
+
"tui.layout",
|
|
60
|
+
"tui.widgets.session_bar",
|
|
61
|
+
"tui.widgets.context_sidebar",
|
|
62
|
+
"tui.widgets.footer_bar",
|
|
63
|
+
"tui.widgets.provider_selector",
|
|
54
64
|
"tui.widgets.permission_popup",
|
|
55
65
|
"tui.widgets.tool_activity",
|
|
56
66
|
"tui.widgets.slash_palette",
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Unit: AgentController — loop turn headless tanpa Textual (R4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
from core.agent_events import AgentContinue, AgentDone
|
|
8
|
+
from core.llm_client import StreamDone, ToolCallRequest
|
|
9
|
+
from core.session_state import AgentStatus, SessionState
|
|
10
|
+
from memory.context import ConversationContext
|
|
11
|
+
from tui.controllers.agent_controller import AgentController, TurnHooks
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _run(coro):
|
|
15
|
+
return asyncio.run(coro)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def _no_continue(ev: AgentContinue) -> bool:
|
|
19
|
+
raise AssertionError("tak boleh Continue di sini")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def _no_ask(question: str) -> str:
|
|
23
|
+
raise AssertionError("tak boleh ask")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _no_live(call_id: str, line: str) -> None:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _hooks(emit, confirm, cont=_no_continue):
|
|
31
|
+
return TurnHooks(emit=emit, continue_prompt=cont, confirm=confirm,
|
|
32
|
+
ask_user=_no_ask, live=_no_live)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_plain_turn_emits_and_idles(sample_config, fake_llm_factory):
|
|
36
|
+
ctx = ConversationContext()
|
|
37
|
+
ctl = AgentController(SessionState(), sample_config, ctx)
|
|
38
|
+
ctl.llm_client = fake_llm_factory([StreamDone("halo dunia", [])])
|
|
39
|
+
seen: list[str] = []
|
|
40
|
+
|
|
41
|
+
async def _emit(ev):
|
|
42
|
+
seen.append(type(ev).__name__)
|
|
43
|
+
|
|
44
|
+
async def _deny(name, params):
|
|
45
|
+
raise AssertionError("tak boleh confirm")
|
|
46
|
+
|
|
47
|
+
_run(ctl.run_turn("hi", _hooks(_emit, _deny)))
|
|
48
|
+
assert seen[0] == "AgentText" and seen[-1] == "AgentDone", seen
|
|
49
|
+
assert ctl.session.status is AgentStatus.IDLE
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_session_approve_persists_across_turns(sample_config,
|
|
53
|
+
fake_llm_factory):
|
|
54
|
+
"""Regression #32: [A] di turn 1 → turn 2 auto tanpa confirm."""
|
|
55
|
+
ctx = ConversationContext()
|
|
56
|
+
ctl = AgentController(SessionState(), sample_config, ctx)
|
|
57
|
+
seen: list[str] = []
|
|
58
|
+
|
|
59
|
+
async def _emit(ev):
|
|
60
|
+
seen.append(type(ev).__name__)
|
|
61
|
+
|
|
62
|
+
ctl.llm_client = fake_llm_factory([
|
|
63
|
+
StreamDone("", [ToolCallRequest(id="c1", name="bash",
|
|
64
|
+
arguments={"command": "echo a"})]),
|
|
65
|
+
StreamDone("satu", []),
|
|
66
|
+
])
|
|
67
|
+
calls: list[str] = []
|
|
68
|
+
|
|
69
|
+
async def _all(name, params):
|
|
70
|
+
calls.append(name)
|
|
71
|
+
return "all"
|
|
72
|
+
|
|
73
|
+
_run(ctl.run_turn("run", _hooks(_emit, _all), active_tools=["bash"]))
|
|
74
|
+
assert calls == ["bash"]
|
|
75
|
+
assert isinstance(seen[-1], str) and "AgentToolDone" in seen
|
|
76
|
+
|
|
77
|
+
ctl.llm_client = fake_llm_factory([
|
|
78
|
+
StreamDone("", [ToolCallRequest(id="c2", name="bash",
|
|
79
|
+
arguments={"command": "echo b"})]),
|
|
80
|
+
StreamDone("dua", []),
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
async def _boom(name, params):
|
|
84
|
+
raise AssertionError("turn 2 harus auto (approve sesi persist)")
|
|
85
|
+
|
|
86
|
+
_run(ctl.run_turn("lagi", _hooks(_emit, _boom), active_tools=["bash"]))
|
|
87
|
+
assert ctx.get_messages()[-2]["content"] == "b" # hasil echo b
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_continue_prompt_continues_without_reset(sample_config,
|
|
91
|
+
fake_llm_factory):
|
|
92
|
+
sample_config.max_tool_iterations = 1
|
|
93
|
+
ctx = ConversationContext()
|
|
94
|
+
ctl = AgentController(SessionState(), sample_config, ctx)
|
|
95
|
+
ctl.llm_client = fake_llm_factory([
|
|
96
|
+
StreamDone("", [ToolCallRequest(id="c1", name="list_dir",
|
|
97
|
+
arguments={"path": "."})]),
|
|
98
|
+
StreamDone("", [ToolCallRequest(id="c2", name="list_dir",
|
|
99
|
+
arguments={"path": "."})]),
|
|
100
|
+
StreamDone("selesai", []),
|
|
101
|
+
])
|
|
102
|
+
dones = 0
|
|
103
|
+
|
|
104
|
+
async def _emit(ev):
|
|
105
|
+
nonlocal dones
|
|
106
|
+
if isinstance(ev, AgentDone):
|
|
107
|
+
dones += 1
|
|
108
|
+
|
|
109
|
+
async def _yes(name, params):
|
|
110
|
+
return "yes"
|
|
111
|
+
|
|
112
|
+
async def _lanjut(ev: AgentContinue) -> bool:
|
|
113
|
+
assert ev.limit == 1
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
_run(ctl.run_turn("loop", _hooks(_emit, _yes, _lanjut)))
|
|
117
|
+
assert dones == 1 # AgentContinue di-swalallow controller, user terima Done
|
|
118
|
+
assert ctl.session.status is AgentStatus.IDLE
|