multacd 2.0.0b7__tar.gz → 2.0.0b8__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.
Files changed (205) hide show
  1. {multacd-2.0.0b7 → multacd-2.0.0b8}/.github/workflows/release.yml +1 -1
  2. {multacd-2.0.0b7 → multacd-2.0.0b8}/CHANGELOG.md +9 -0
  3. {multacd-2.0.0b7 → multacd-2.0.0b8}/PKG-INFO +1 -1
  4. multacd-2.0.0b8/core/agent_events.py +86 -0
  5. {multacd-2.0.0b7 → multacd-2.0.0b8}/core/agent_loop.py +30 -56
  6. multacd-2.0.0b8/core/research/bus.py +66 -0
  7. multacd-2.0.0b8/core/session_state.py +127 -0
  8. {multacd-2.0.0b7 → multacd-2.0.0b8}/core/version.py +1 -1
  9. {multacd-2.0.0b7 → multacd-2.0.0b8}/pyproject.toml +1 -1
  10. {multacd-2.0.0b7 → multacd-2.0.0b8}/scripts/install.sh +1 -1
  11. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/test_selftests.py +4 -0
  12. multacd-2.0.0b8/tests/unit/test_agent_controller.py +118 -0
  13. multacd-2.0.0b8/tests/unit/test_research_bus.py +69 -0
  14. multacd-2.0.0b8/tests/unit/test_session_state.py +66 -0
  15. multacd-2.0.0b8/tests/unit/test_splash.py +20 -0
  16. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/research/quick_research.py +9 -2
  17. multacd-2.0.0b8/tui/controllers/agent_controller.py +184 -0
  18. {multacd-2.0.0b7 → multacd-2.0.0b8}/tui/screens/main_screen.py +101 -92
  19. multacd-2.0.0b8/tui/widgets/__init__.py +0 -0
  20. {multacd-2.0.0b7 → multacd-2.0.0b8}/tui/widgets/chat_panel.py +48 -9
  21. {multacd-2.0.0b7 → multacd-2.0.0b8}/tui/widgets/status_bar.py +31 -0
  22. {multacd-2.0.0b7 → multacd-2.0.0b8}/tui/widgets/thinking_bar.py +43 -0
  23. multacd-2.0.0b7/core/research/bus.py +0 -41
  24. {multacd-2.0.0b7 → multacd-2.0.0b8}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  25. {multacd-2.0.0b7 → multacd-2.0.0b8}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  26. {multacd-2.0.0b7 → multacd-2.0.0b8}/.github/pull_request_template.md +0 -0
  27. {multacd-2.0.0b7 → multacd-2.0.0b8}/.github/workflows/test.yml +0 -0
  28. {multacd-2.0.0b7 → multacd-2.0.0b8}/.gitignore +0 -0
  29. {multacd-2.0.0b7 → multacd-2.0.0b8}/CONTRIBUTING.md +0 -0
  30. {multacd-2.0.0b7 → multacd-2.0.0b8}/DESIGN.md +0 -0
  31. {multacd-2.0.0b7 → multacd-2.0.0b8}/LICENSE +0 -0
  32. {multacd-2.0.0b7 → multacd-2.0.0b8}/README.md +0 -0
  33. {multacd-2.0.0b7 → multacd-2.0.0b8}/briefing/__init__.py +0 -0
  34. {multacd-2.0.0b7 → multacd-2.0.0b8}/briefing/generator.py +0 -0
  35. {multacd-2.0.0b7 → multacd-2.0.0b8}/briefing/privacy.py +0 -0
  36. {multacd-2.0.0b7 → multacd-2.0.0b8}/briefing/sources/__init__.py +0 -0
  37. {multacd-2.0.0b7 → multacd-2.0.0b8}/briefing/sources/git_source.py +0 -0
  38. {multacd-2.0.0b7 → multacd-2.0.0b8}/briefing/sources/news_source.py +0 -0
  39. {multacd-2.0.0b7 → multacd-2.0.0b8}/briefing/sources/todo_source.py +0 -0
  40. {multacd-2.0.0b7 → multacd-2.0.0b8}/core/__init__.py +0 -0
  41. {multacd-2.0.0b7 → multacd-2.0.0b8}/core/clipboard.py +0 -0
  42. {multacd-2.0.0b7 → multacd-2.0.0b8}/core/codebase.py +0 -0
  43. {multacd-2.0.0b7 → multacd-2.0.0b8}/core/config.py +0 -0
  44. {multacd-2.0.0b7 → multacd-2.0.0b8}/core/llm_client.py +0 -0
  45. {multacd-2.0.0b7 → multacd-2.0.0b8}/core/mode_manager.py +0 -0
  46. {multacd-2.0.0b7 → multacd-2.0.0b8}/core/permissions.py +0 -0
  47. {multacd-2.0.0b7 → multacd-2.0.0b8}/core/plugin_loader.py +0 -0
  48. {multacd-2.0.0b7 → multacd-2.0.0b8}/core/prompt_composer.py +0 -0
  49. {multacd-2.0.0b7 → multacd-2.0.0b8}/core/providers/__init__.py +0 -0
  50. {multacd-2.0.0b7 → multacd-2.0.0b8}/core/providers/__main__.py +0 -0
  51. {multacd-2.0.0b7 → multacd-2.0.0b8}/core/providers/anthropic.py +0 -0
  52. {multacd-2.0.0b7 → multacd-2.0.0b8}/core/providers/openai_compat.py +0 -0
  53. {multacd-2.0.0b7 → multacd-2.0.0b8}/core/research/__init__.py +0 -0
  54. {multacd-2.0.0b7 → multacd-2.0.0b8}/core/research/orchestrator.py +0 -0
  55. {multacd-2.0.0b7 → multacd-2.0.0b8}/core/research/query_generator.py +0 -0
  56. {multacd-2.0.0b7 → multacd-2.0.0b8}/core/updater.py +0 -0
  57. {multacd-2.0.0b7 → multacd-2.0.0b8}/daemon/__init__.py +0 -0
  58. {multacd-2.0.0b7 → multacd-2.0.0b8}/daemon/ipc.py +0 -0
  59. {multacd-2.0.0b7 → multacd-2.0.0b8}/daemon/process.py +0 -0
  60. {multacd-2.0.0b7 → multacd-2.0.0b8}/docs/configuration.md +0 -0
  61. {multacd-2.0.0b7 → multacd-2.0.0b8}/docs/index.md +0 -0
  62. {multacd-2.0.0b7 → multacd-2.0.0b8}/docs/install.md +0 -0
  63. {multacd-2.0.0b7 → multacd-2.0.0b8}/docs/mkdocs.yml +0 -0
  64. {multacd-2.0.0b7 → multacd-2.0.0b8}/docs/modes/coding.md +0 -0
  65. {multacd-2.0.0b7 → multacd-2.0.0b8}/docs/modes/personal.md +0 -0
  66. {multacd-2.0.0b7 → multacd-2.0.0b8}/docs/modes/research.md +0 -0
  67. {multacd-2.0.0b7 → multacd-2.0.0b8}/docs/plugins.md +0 -0
  68. {multacd-2.0.0b7 → multacd-2.0.0b8}/docs/quickstart.md +0 -0
  69. {multacd-2.0.0b7 → multacd-2.0.0b8}/main.py +0 -0
  70. {multacd-2.0.0b7 → multacd-2.0.0b8}/memory/__init__.py +0 -0
  71. {multacd-2.0.0b7 → multacd-2.0.0b8}/memory/context.py +0 -0
  72. {multacd-2.0.0b7 → multacd-2.0.0b8}/memory/store.py +0 -0
  73. {multacd-2.0.0b7 → multacd-2.0.0b8}/multacd.spec +0 -0
  74. {multacd-2.0.0b7 → multacd-2.0.0b8}/plan/PLAN-phase1.md +0 -0
  75. {multacd-2.0.0b7 → multacd-2.0.0b8}/plan/PLAN-phase2.md +0 -0
  76. {multacd-2.0.0b7 → multacd-2.0.0b8}/plugins/README.md +0 -0
  77. {multacd-2.0.0b7 → multacd-2.0.0b8}/plugins/example_plugin.py +0 -0
  78. {multacd-2.0.0b7 → multacd-2.0.0b8}/requirements.txt +0 -0
  79. {multacd-2.0.0b7 → multacd-2.0.0b8}/roadmap/ROADMAP.md +0 -0
  80. {multacd-2.0.0b7 → multacd-2.0.0b8}/scheduler/__init__.py +0 -0
  81. {multacd-2.0.0b7 → multacd-2.0.0b8}/scheduler/engine.py +0 -0
  82. {multacd-2.0.0b7 → multacd-2.0.0b8}/scheduler/jobs/__init__.py +0 -0
  83. {multacd-2.0.0b7 → multacd-2.0.0b8}/scheduler/jobs/briefing_job.py +0 -0
  84. {multacd-2.0.0b7 → multacd-2.0.0b8}/scheduler/jobs/research_job.py +0 -0
  85. {multacd-2.0.0b7 → multacd-2.0.0b8}/scripts/install.ps1 +0 -0
  86. {multacd-2.0.0b7 → multacd-2.0.0b8}/search_providers/__init__.py +0 -0
  87. {multacd-2.0.0b7 → multacd-2.0.0b8}/search_providers/__main__.py +0 -0
  88. {multacd-2.0.0b7 → multacd-2.0.0b8}/search_providers/base.py +0 -0
  89. {multacd-2.0.0b7 → multacd-2.0.0b8}/search_providers/brave.py +0 -0
  90. {multacd-2.0.0b7 → multacd-2.0.0b8}/search_providers/duckduckgo.py +0 -0
  91. {multacd-2.0.0b7 → multacd-2.0.0b8}/search_providers/exa.py +0 -0
  92. {multacd-2.0.0b7 → multacd-2.0.0b8}/search_providers/serpapi.py +0 -0
  93. {multacd-2.0.0b7 → multacd-2.0.0b8}/search_providers/tavily.py +0 -0
  94. {multacd-2.0.0b7 → multacd-2.0.0b8}/soul.md +0 -0
  95. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/__init__.py +0 -0
  96. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/conftest.py +0 -0
  97. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/integration/__init__.py +0 -0
  98. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/integration/test_agent_loop.py +0 -0
  99. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/unit/__init__.py +0 -0
  100. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/unit/test_binary.py +0 -0
  101. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/unit/test_config.py +0 -0
  102. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/unit/test_docs.py +0 -0
  103. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/unit/test_entrypoint.py +0 -0
  104. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/unit/test_github.py +0 -0
  105. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/unit/test_installer.py +0 -0
  106. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/unit/test_permissions.py +0 -0
  107. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/unit/test_plugin_loader.py +0 -0
  108. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/unit/test_privacy_filter.py +0 -0
  109. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/unit/test_provider_keys.py +0 -0
  110. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/unit/test_search_providers/__init__.py +0 -0
  111. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/unit/test_search_providers/test_fallback.py +0 -0
  112. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/unit/test_search_providers/test_tavily.py +0 -0
  113. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/unit/test_tools/__init__.py +0 -0
  114. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/unit/test_tools/test_bash.py +0 -0
  115. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/unit/test_tools/test_read_file.py +0 -0
  116. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/unit/test_tools/test_web_scrape.py +0 -0
  117. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/unit/test_tools/test_write_file.py +0 -0
  118. {multacd-2.0.0b7 → multacd-2.0.0b8}/tests/unit/test_updater.py +0 -0
  119. {multacd-2.0.0b7 → multacd-2.0.0b8}/tg/__init__.py +0 -0
  120. {multacd-2.0.0b7 → multacd-2.0.0b8}/tg/access_control.py +0 -0
  121. {multacd-2.0.0b7 → multacd-2.0.0b8}/tg/agent.py +0 -0
  122. {multacd-2.0.0b7 → multacd-2.0.0b8}/tg/bot.py +0 -0
  123. {multacd-2.0.0b7 → multacd-2.0.0b8}/tg/file_handler.py +0 -0
  124. {multacd-2.0.0b7 → multacd-2.0.0b8}/tg/formatter.py +0 -0
  125. {multacd-2.0.0b7 → multacd-2.0.0b8}/tg/handlers.py +0 -0
  126. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/__init__.py +0 -0
  127. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/agent/__init__.py +0 -0
  128. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/agent/ask.py +0 -0
  129. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/agent/skill.py +0 -0
  130. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/agent/task.py +0 -0
  131. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/agent/todo_write.py +0 -0
  132. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/code/__init__.py +0 -0
  133. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/code/lint_python.py +0 -0
  134. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/code/run_python.py +0 -0
  135. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/code/run_tests.py +0 -0
  136. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/codebase/__init__.py +0 -0
  137. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/codebase/scan_codebase.py +0 -0
  138. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/common.py +0 -0
  139. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/filesystem/__init__.py +0 -0
  140. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/filesystem/apply_patch.py +0 -0
  141. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/filesystem/delete_file.py +0 -0
  142. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/filesystem/edit_file.py +0 -0
  143. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/filesystem/glob.py +0 -0
  144. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/filesystem/grep.py +0 -0
  145. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/filesystem/list_dir.py +0 -0
  146. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/filesystem/move_file.py +0 -0
  147. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/filesystem/multi_edit.py +0 -0
  148. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/filesystem/read_file.py +0 -0
  149. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/filesystem/read_many_files.py +0 -0
  150. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/filesystem/write_file.py +0 -0
  151. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/git/__init__.py +0 -0
  152. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/git/_helper.py +0 -0
  153. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/git/git_add.py +0 -0
  154. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/git/git_branch.py +0 -0
  155. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/git/git_checkout.py +0 -0
  156. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/git/git_commit.py +0 -0
  157. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/git/git_diff.py +0 -0
  158. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/git/git_log.py +0 -0
  159. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/git/git_merge.py +0 -0
  160. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/git/git_pull.py +0 -0
  161. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/git/git_push.py +0 -0
  162. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/git/git_status.py +0 -0
  163. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/memory/__init__.py +0 -0
  164. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/memory/forget.py +0 -0
  165. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/memory/recall.py +0 -0
  166. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/memory/remember.py +0 -0
  167. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/personal/__init__.py +0 -0
  168. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/personal/cancel_job.py +0 -0
  169. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/personal/daemon_status.py +0 -0
  170. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/personal/generate_briefing.py +0 -0
  171. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/personal/get_jobs.py +0 -0
  172. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/personal/schedule_job.py +0 -0
  173. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/personal/send_telegram.py +0 -0
  174. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/personal/user_manager.py +0 -0
  175. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/registry.py +0 -0
  176. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/research/__init__.py +0 -0
  177. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/research/deep_research.py +0 -0
  178. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/research/export_research.py +0 -0
  179. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/research/web_scrape.py +0 -0
  180. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/research/web_search.py +0 -0
  181. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/shell/__init__.py +0 -0
  182. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/shell/bash.py +0 -0
  183. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/streaming.py +0 -0
  184. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/web/__init__.py +0 -0
  185. {multacd-2.0.0b7 → multacd-2.0.0b8}/tools/web/web_fetch.py +0 -0
  186. {multacd-2.0.0b7 → multacd-2.0.0b8}/tui/__init__.py +0 -0
  187. {multacd-2.0.0b7 → multacd-2.0.0b8}/tui/app.py +0 -0
  188. {multacd-2.0.0b7/tui/screens → multacd-2.0.0b8/tui/controllers}/__init__.py +0 -0
  189. {multacd-2.0.0b7 → multacd-2.0.0b8}/tui/icons.py +0 -0
  190. {multacd-2.0.0b7 → multacd-2.0.0b8}/tui/markup_safe.py +0 -0
  191. {multacd-2.0.0b7/tui/widgets → multacd-2.0.0b8/tui/screens}/__init__.py +0 -0
  192. {multacd-2.0.0b7 → multacd-2.0.0b8}/tui/screens/setup_wizard.py +0 -0
  193. {multacd-2.0.0b7 → multacd-2.0.0b8}/tui/themes/__init__.py +0 -0
  194. {multacd-2.0.0b7 → multacd-2.0.0b8}/tui/themes/__main__.py +0 -0
  195. {multacd-2.0.0b7 → multacd-2.0.0b8}/tui/tokens.py +0 -0
  196. {multacd-2.0.0b7 → multacd-2.0.0b8}/tui/widgets/confirm_dialog.py +0 -0
  197. {multacd-2.0.0b7 → multacd-2.0.0b8}/tui/widgets/diff_viewer.py +0 -0
  198. {multacd-2.0.0b7 → multacd-2.0.0b8}/tui/widgets/file_tree.py +0 -0
  199. {multacd-2.0.0b7 → multacd-2.0.0b8}/tui/widgets/info_panel.py +0 -0
  200. {multacd-2.0.0b7 → multacd-2.0.0b8}/tui/widgets/input_bar.py +0 -0
  201. {multacd-2.0.0b7 → multacd-2.0.0b8}/tui/widgets/model_selector.py +0 -0
  202. {multacd-2.0.0b7 → multacd-2.0.0b8}/tui/widgets/permission_popup.py +0 -0
  203. {multacd-2.0.0b7 → multacd-2.0.0b8}/tui/widgets/slash_palette.py +0 -0
  204. {multacd-2.0.0b7 → multacd-2.0.0b8}/tui/widgets/sources_panel.py +0 -0
  205. {multacd-2.0.0b7 → multacd-2.0.0b8}/tui/widgets/tool_activity.py +0 -0
@@ -101,7 +101,7 @@ jobs:
101
101
 
102
102
  ```bash
103
103
  pkg install -y python git
104
- pip install "multacd==2.0.0b7"
104
+ pip install "multacd==2.0.0b8"
105
105
  ```
106
106
 
107
107
  Windows (PowerShell):
@@ -2,6 +2,15 @@
2
2
 
3
3
  Format: Keep a Changelog. Versi: Semantic Versioning.
4
4
 
5
+ ## [2.0.0b8] - 2026-09-13
6
+
7
+ Beta v2 kedelapan (refactor arsitektur, tanpa ubah fitur): kontrak event
8
+ `core/agent_events` (R2), SessionState + reducer keluar widget (R3),
9
+ AgentController + fix approve [A] lintas turn (#32, R4), widget render
10
+ dari state (R5), research sink task-scoped (R6), splash §10 lengkap (R7).
11
+
12
+ Install: `pip install "multacd==2.0.0b8"` (tanpa --pre).
13
+
5
14
  ## [2.0.0b7] - 2026-09-12
6
15
 
7
16
  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.0b7
3
+ Version: 2.0.0b8
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
- @dataclass
58
- class AgentText:
59
- delta: str
60
-
61
-
62
- @dataclass
63
- class AgentToolStart:
64
- call_id: str
65
- name: str
66
- params: dict[str, Any]
67
-
68
-
69
- @dataclass
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.0b7" # beta v2; samakan dengan pyproject.toml
11
+ FALLBACK = "2.0.0b8" # beta v2; samakan dengan pyproject.toml
12
12
 
13
13
 
14
14
  def get_version() -> str:
@@ -8,7 +8,7 @@ build-backend = "hatchling.build"
8
8
 
9
9
  [project]
10
10
  name = "multacd"
11
- version = "2.0.0b7"
11
+ version = "2.0.0b8"
12
12
  description = "Agentic TUI for coding, research, and personal automation"
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.10"
@@ -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.0b7\""
81
+ echo " pip install \"multacd==2.0.0b8\""
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,7 @@ SELF_TEST_MODULES = [
51
54
  "tui.themes",
52
55
  "tui.markup_safe",
53
56
  "tui.widgets.thinking_bar",
57
+ "tui.widgets.status_bar",
54
58
  "tui.widgets.permission_popup",
55
59
  "tui.widgets.tool_activity",
56
60
  "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
@@ -0,0 +1,69 @@
1
+ """Unit: research bus task-scoped — dua sesi tak saling timpa (R6)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from core.research.bus import get_research_sink, set_research_sink
8
+ from tools.research.quick_research import _run_coro
9
+
10
+
11
+ def _run(coro):
12
+ return asyncio.run(coro)
13
+
14
+
15
+ def test_tasks_do_not_clobber_each_other():
16
+ async def _main():
17
+ async def _isi(nama: str, wadah: list) -> None:
18
+ set_research_sink(wadah.append)
19
+ await asyncio.sleep(0.01) # interleave
20
+ get_research_sink()({"type": nama})
21
+
22
+ a: list = []
23
+ b: list = []
24
+ await asyncio.gather(_isi("a", a), _isi("b", b))
25
+ assert a == [{"type": "a"}]
26
+ assert b == [{"type": "b"}]
27
+
28
+ _run(_main())
29
+ assert get_research_sink() is None # konteks luar tak tercemar
30
+
31
+
32
+ def test_reset_only_affects_own_task():
33
+ async def _main():
34
+ set_research_sink(lambda ev: None)
35
+ assert get_research_sink() is not None
36
+ set_research_sink(None)
37
+ assert get_research_sink() is None
38
+
39
+ _run(_main())
40
+ assert get_research_sink() is None
41
+
42
+
43
+ def test_sink_survives_run_coro_pool_boundary():
44
+ """Rantai TUI: task → to_thread → _run_coro(pool) → sink tetap sama."""
45
+
46
+ async def _coro_read():
47
+ from core.research.bus import get_research_sink as _get
48
+ return _get()
49
+
50
+ async def _main():
51
+ marker = object()
52
+ set_research_sink(marker) # type: ignore[arg-type]
53
+ # _run_coro dari dalam loop = jalur agent_loop (pool thread).
54
+ assert _run_coro(_coro_read()) is marker
55
+ # to_thread juga mewarisi context.
56
+ seen = await asyncio.to_thread(get_research_sink)
57
+ assert seen is marker
58
+ set_research_sink(None)
59
+
60
+ _run(_main())
61
+
62
+
63
+ def test_no_sink_outside_turn():
64
+ async def _coro_read():
65
+ from core.research.bus import get_research_sink as _get
66
+ return _get()
67
+
68
+ # Konteks sync tanpa set → asyncio.run biasa → None.
69
+ assert _run_coro(_coro_read()) is None