tsugite-cli 0.12.0__tar.gz → 0.12.2__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 (347) hide show
  1. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/PKG-INFO +1 -1
  2. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/pyproject.toml +1 -1
  3. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/core/test_agent.py +83 -6
  4. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/daemon/test_http_adapter.py +87 -0
  5. tsugite_cli-0.12.2/tests/e2e/test_history_code_rendering.py +233 -0
  6. tsugite_cli-0.12.2/tests/e2e/test_markdown_rendering.py +205 -0
  7. tsugite_cli-0.12.2/tests/e2e/test_message_actions.py +260 -0
  8. tsugite_cli-0.12.2/tests/e2e/test_scroll_behavior.py +143 -0
  9. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_history_integration.py +77 -2
  10. tsugite_cli-0.12.2/tests/test_md_agents_json.py +99 -0
  11. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_skill_discovery.py +24 -0
  12. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/agent_runner/history_integration.py +14 -9
  13. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/core/agent.py +8 -13
  14. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/adapters/http.py +18 -17
  15. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/web/css/styles.css +64 -0
  16. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/web/index.html +29 -2
  17. tsugite_cli-0.12.2/tsugite/daemon/web/js/utils.js +100 -0
  18. tsugite_cli-0.12.2/tsugite/daemon/web/js/vendor/marked.LICENSE.md +44 -0
  19. tsugite_cli-0.12.2/tsugite/daemon/web/js/vendor/marked.esm.min.js +9 -0
  20. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/web/js/views/conversation/history.js +30 -14
  21. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/web/js/views/conversation/streaming.js +4 -4
  22. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/web/js/views/conversations.js +27 -3
  23. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/md_agents.py +20 -45
  24. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/skill_discovery.py +11 -2
  25. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/uv.lock +2 -2
  26. tsugite_cli-0.12.0/tsugite/daemon/web/js/utils.js +0 -102
  27. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/.github/copilot-instructions.md +0 -0
  28. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/.github/workflows/ci.yml +0 -0
  29. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/.github/workflows/docker-publish.yml +0 -0
  30. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/.github/workflows/pypi-publish.yml +0 -0
  31. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/.gitignore +0 -0
  32. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/AGENTS.md +0 -0
  33. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/CLAUDE.md +0 -0
  34. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/LICENSE +0 -0
  35. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/README.md +0 -0
  36. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/docs/agents.md +0 -0
  37. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/docs/plugin-hooks.md +0 -0
  38. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/docs/secrets.md +0 -0
  39. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/docs/test_agents/prefresh_readme.md +0 -0
  40. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/docs/test_agents/template_test.md +0 -0
  41. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/docs/test_agents/test_agent.md +0 -0
  42. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/docs/test_agents/test_steps.md +0 -0
  43. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/mise.toml +0 -0
  44. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/scripts/backfill_usage.py +0 -0
  45. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/scripts/regenerate_schema.py +0 -0
  46. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/scripts/update_model_registry.py +0 -0
  47. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/README.md +0 -0
  48. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/__init__.py +0 -0
  49. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/conftest.py +0 -0
  50. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/core/__init__.py +0 -0
  51. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/core/test_agent_context_tokens.py +0 -0
  52. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/core/test_agent_ui_events.py +0 -0
  53. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/core/test_content_blocks.py +0 -0
  54. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/core/test_executor.py +0 -0
  55. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/core/test_memory.py +0 -0
  56. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/core/test_proxy.py +0 -0
  57. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/core/test_sandbox.py +0 -0
  58. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/core/test_subprocess_executor.py +0 -0
  59. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/core/test_tools.py +0 -0
  60. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/daemon/__init__.py +0 -0
  61. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/daemon/test_session_metadata_api.py +0 -0
  62. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/e2e/__init__.py +0 -0
  63. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/e2e/conftest.py +0 -0
  64. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/e2e/test_auth.py +0 -0
  65. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/e2e/test_chat.py +0 -0
  66. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/e2e/test_context_bar.py +0 -0
  67. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/e2e/test_draft_persistence.py +0 -0
  68. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/e2e/test_history.py +0 -0
  69. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/e2e/test_page_load.py +0 -0
  70. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/e2e/test_prompt_inspector.py +0 -0
  71. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/e2e/test_sessions.py +0 -0
  72. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/e2e/test_sidebar_redesign.py +0 -0
  73. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/e2e/test_sse_metadata.py +0 -0
  74. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/events/test_event_consolidation.py +0 -0
  75. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/smoke_test.sh +0 -0
  76. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_agent_file_hot_loading.py +0 -0
  77. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_agent_inheritance.py +0 -0
  78. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_agent_parser.py +0 -0
  79. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_agent_sessions.py +0 -0
  80. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_agent_skills.py +0 -0
  81. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_agent_utils.py +0 -0
  82. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_agents_tool.py +0 -0
  83. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_attachment_deduplication.py +0 -0
  84. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_attachments.py +0 -0
  85. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_auto_context_handler.py +0 -0
  86. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_auto_discovery.py +0 -0
  87. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_background_task_status.py +0 -0
  88. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_background_tasks.py +0 -0
  89. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_builtin_agent_paths.py +0 -0
  90. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_builtin_agents.py +0 -0
  91. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_cache_control.py +0 -0
  92. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_chat_cli.py +0 -0
  93. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_chat_error_handling.py +0 -0
  94. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_claude_code_attachments.py +0 -0
  95. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_claude_code_provider.py +0 -0
  96. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_cli.py +0 -0
  97. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_cli_arguments.py +0 -0
  98. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_cli_rendering.py +0 -0
  99. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_cli_subcommands.py +0 -0
  100. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_completion_callbacks.py +0 -0
  101. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_config.py +0 -0
  102. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_continuation.py +0 -0
  103. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_custom_shell_tools.py +0 -0
  104. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_custom_ui.py +0 -0
  105. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_daemon_auth.py +0 -0
  106. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_daemon_compaction_scheduler.py +0 -0
  107. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_daemon_config.py +0 -0
  108. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_daemon_history_persistence.py +0 -0
  109. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_daemon_memory.py +0 -0
  110. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_daemon_push.py +0 -0
  111. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_daemon_scheduler.py +0 -0
  112. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_daemon_session_isolation.py +0 -0
  113. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_daemon_unified_sessions.py +0 -0
  114. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_discord_progress.py +0 -0
  115. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_error_display.py +0 -0
  116. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_file_references.py +0 -0
  117. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_file_tools.py +0 -0
  118. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_history.py +0 -0
  119. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_history_models.py +0 -0
  120. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_history_performance.py +0 -0
  121. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_history_tools.py +0 -0
  122. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_hooks.py +0 -0
  123. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_http_tools.py +0 -0
  124. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_interaction_backends.py +0 -0
  125. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_interactive_context.py +0 -0
  126. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_interactive_tool.py +0 -0
  127. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_jsonl_ui.py +0 -0
  128. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_list_agents_tool.py +0 -0
  129. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_mcp_client.py +0 -0
  130. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_mcp_server.py +0 -0
  131. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_model_registry.py +0 -0
  132. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_models.py +0 -0
  133. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_multi_agent.py +0 -0
  134. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_multistep_agents.py +0 -0
  135. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_orchestrator_heartbeat.py +0 -0
  136. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_plugins.py +0 -0
  137. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_prompt_snapshot.py +0 -0
  138. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_provider_registry.py +0 -0
  139. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_reasoning_models.py +0 -0
  140. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_renderer.py +0 -0
  141. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_rendering_scenarios.py +0 -0
  142. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_repl_commands.py +0 -0
  143. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_repl_completer.py +0 -0
  144. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_repl_handler.py +0 -0
  145. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_retry_system.py +0 -0
  146. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_run_if.py +0 -0
  147. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_schedule_model_override.py +0 -0
  148. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_scheduler_history_injection.py +0 -0
  149. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_schema.py +0 -0
  150. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_secret_access_event.py +0 -0
  151. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_secrets.py +0 -0
  152. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_security_phase1.py +0 -0
  153. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_send_message.py +0 -0
  154. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_session_metadata.py +0 -0
  155. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_session_orchestrator_tools.py +0 -0
  156. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_skill_tools.py +0 -0
  157. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_stdin.py +0 -0
  158. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_subagent_subprocess.py +0 -0
  159. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_tmux_tools.py +0 -0
  160. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_tool_directives.py +0 -0
  161. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_tool_registry.py +0 -0
  162. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_workspace_auto_continue.py +0 -0
  163. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_workspace_cwd.py +0 -0
  164. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tests/test_workspace_discovery.py +0 -0
  165. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/__init__.py +0 -0
  166. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/agent_inheritance.py +0 -0
  167. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/agent_preparation.py +0 -0
  168. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/agent_runner/__init__.py +0 -0
  169. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/agent_runner/helpers.py +0 -0
  170. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/agent_runner/metrics.py +0 -0
  171. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/agent_runner/models.py +0 -0
  172. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/agent_runner/runner.py +0 -0
  173. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/agent_runner/validation.py +0 -0
  174. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/agent_utils.py +0 -0
  175. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/attachments/__init__.py +0 -0
  176. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/attachments/auto_context.py +0 -0
  177. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/attachments/base.py +0 -0
  178. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/attachments/file.py +0 -0
  179. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/attachments/inline.py +0 -0
  180. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/attachments/storage.py +0 -0
  181. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/attachments/url.py +0 -0
  182. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/attachments/youtube.py +0 -0
  183. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/builtin_agents/.gitkeep +0 -0
  184. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/builtin_agents/code_searcher.md +0 -0
  185. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/builtin_agents/default.md +0 -0
  186. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/builtin_agents/file_searcher.md +0 -0
  187. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/builtin_agents/onboard.md +0 -0
  188. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/builtin_skills/.gitkeep +0 -0
  189. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/builtin_skills/codebase-exploration/SKILL.md +0 -0
  190. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/builtin_skills/python-math/SKILL.md +0 -0
  191. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/builtin_skills/response-patterns/SKILL.md +0 -0
  192. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/builtin_skills/scheduling/SKILL.md +0 -0
  193. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/builtin_skills/skill-authoring/SKILL.md +0 -0
  194. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/builtin_skills/tsugite-agent-basics/SKILL.md +0 -0
  195. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/builtin_skills/tsugite-jinja-reference/SKILL.md +0 -0
  196. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/builtin_skills/tsugite-skill-basics/SKILL.md +0 -0
  197. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/cache.py +0 -0
  198. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/cli/__init__.py +0 -0
  199. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/cli/agents.py +0 -0
  200. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/cli/attachments.py +0 -0
  201. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/cli/cache.py +0 -0
  202. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/cli/chat.py +0 -0
  203. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/cli/config.py +0 -0
  204. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/cli/daemon.py +0 -0
  205. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/cli/helpers.py +0 -0
  206. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/cli/history.py +0 -0
  207. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/cli/init.py +0 -0
  208. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/cli/mcp.py +0 -0
  209. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/cli/models.py +0 -0
  210. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/cli/plugins.py +0 -0
  211. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/cli/render.py +0 -0
  212. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/cli/run.py +0 -0
  213. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/cli/secrets.py +0 -0
  214. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/cli/serve.py +0 -0
  215. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/cli/tools.py +0 -0
  216. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/cli/usage.py +0 -0
  217. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/cli/validate.py +0 -0
  218. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/cli/workspace.py +0 -0
  219. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/config.py +0 -0
  220. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/console.py +0 -0
  221. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/constants.py +0 -0
  222. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/core/__init__.py +0 -0
  223. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/core/claude_code.py +0 -0
  224. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/core/content_blocks.py +0 -0
  225. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/core/executor.py +0 -0
  226. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/core/memory.py +0 -0
  227. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/core/proxy.py +0 -0
  228. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/core/sandbox.py +0 -0
  229. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/core/subprocess_executor.py +0 -0
  230. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/core/tools.py +0 -0
  231. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/__init__.py +0 -0
  232. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/adapters/__init__.py +0 -0
  233. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/adapters/base.py +0 -0
  234. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/adapters/discord.py +0 -0
  235. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/adapters/scheduler_adapter.py +0 -0
  236. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/auth.py +0 -0
  237. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/commands.py +0 -0
  238. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/compaction_scheduler.py +0 -0
  239. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/config.py +0 -0
  240. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/gateway.py +0 -0
  241. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/memory.py +0 -0
  242. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/push.py +0 -0
  243. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/scheduler.py +0 -0
  244. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/session_runner.py +0 -0
  245. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/session_store.py +0 -0
  246. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/web/css/responsive.css +0 -0
  247. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/web/css/theme.css +0 -0
  248. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/web/icons/icon-192.png +0 -0
  249. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/web/icons/icon-512-maskable.png +0 -0
  250. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/web/icons/icon-512.png +0 -0
  251. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/web/icons/screenshot-narrow.png +0 -0
  252. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/web/icons/screenshot-wide.png +0 -0
  253. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/web/js/api.js +0 -0
  254. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/web/js/app.js +0 -0
  255. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/web/js/views/conversation/attachments.js +0 -0
  256. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/web/js/views/conversation/input.js +0 -0
  257. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/web/js/views/conversation/sessions.js +0 -0
  258. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/web/js/views/file-editor.js +0 -0
  259. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/web/js/views/schedules.js +0 -0
  260. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/web/js/views/usage.js +0 -0
  261. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/web/js/views/webhooks.js +0 -0
  262. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/web/js/views/workspace.js +0 -0
  263. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/web/manifest.json +0 -0
  264. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/web/sw.js +0 -0
  265. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/daemon/webhook_store.py +0 -0
  266. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/events/__init__.py +0 -0
  267. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/events/base.py +0 -0
  268. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/events/bus.py +0 -0
  269. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/events/events.py +0 -0
  270. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/events/helpers.py +0 -0
  271. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/exceptions.py +0 -0
  272. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/history/__init__.py +0 -0
  273. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/history/models.py +0 -0
  274. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/history/reconstruction.py +0 -0
  275. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/history/storage.py +0 -0
  276. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/hooks.py +0 -0
  277. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/interaction.py +0 -0
  278. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/mcp_client.py +0 -0
  279. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/mcp_config.py +0 -0
  280. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/mcp_server.py +0 -0
  281. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/models.py +0 -0
  282. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/options.py +0 -0
  283. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/plugins.py +0 -0
  284. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/providers/__init__.py +0 -0
  285. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/providers/anthropic.py +0 -0
  286. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/providers/base.py +0 -0
  287. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/providers/claude_code.py +0 -0
  288. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/providers/model_cache.py +0 -0
  289. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/providers/model_registry.py +0 -0
  290. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/providers/ollama.py +0 -0
  291. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/providers/openai_compat.py +0 -0
  292. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/providers/openrouter.py +0 -0
  293. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/renderer.py +0 -0
  294. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/schemas/__init__.py +0 -0
  295. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/schemas/agent.schema.json +0 -0
  296. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/secrets/__init__.py +0 -0
  297. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/secrets/backend.py +0 -0
  298. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/secrets/env.py +0 -0
  299. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/secrets/exec.py +0 -0
  300. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/secrets/file.py +0 -0
  301. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/secrets/masking.py +0 -0
  302. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/secrets/registry.py +0 -0
  303. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/secrets/sqlite.py +0 -0
  304. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/shell_tool_config.py +0 -0
  305. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/templates/AGENTS.md +0 -0
  306. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/templates/IDENTITY.md +0 -0
  307. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/templates/MEMORY.md +0 -0
  308. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/templates/USER.md +0 -0
  309. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/templates/personas/casual-technical.md +0 -0
  310. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/templates/personas/marvin.md +0 -0
  311. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/templates/personas/minimal.md +0 -0
  312. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/tools/__init__.py +0 -0
  313. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/tools/agents.py +0 -0
  314. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/tools/fs.py +0 -0
  315. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/tools/history.py +0 -0
  316. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/tools/http.py +0 -0
  317. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/tools/interactive.py +0 -0
  318. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/tools/notify.py +0 -0
  319. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/tools/schedule.py +0 -0
  320. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/tools/scratchpad.py +0 -0
  321. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/tools/secrets.py +0 -0
  322. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/tools/sessions.py +0 -0
  323. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/tools/shell.py +0 -0
  324. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/tools/shell_tools.py +0 -0
  325. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/tools/skills.py +0 -0
  326. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/tools/tmux.py +0 -0
  327. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/tsugite.py +0 -0
  328. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/ui/__init__.py +0 -0
  329. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/ui/base.py +0 -0
  330. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/ui/chat.py +0 -0
  331. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/ui/helpers.py +0 -0
  332. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/ui/jsonl.py +0 -0
  333. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/ui/plain.py +0 -0
  334. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/ui/repl_chat.py +0 -0
  335. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/ui/repl_commands.py +0 -0
  336. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/ui/repl_completer.py +0 -0
  337. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/ui/repl_handler.py +0 -0
  338. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/ui_context.py +0 -0
  339. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/usage/__init__.py +0 -0
  340. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/usage/store.py +0 -0
  341. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/user_agent.py +0 -0
  342. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/utils.py +0 -0
  343. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/workspace/__init__.py +0 -0
  344. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/workspace/context.py +0 -0
  345. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/workspace/models.py +0 -0
  346. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/workspace/session.py +0 -0
  347. {tsugite_cli-0.12.0 → tsugite_cli-0.12.2}/tsugite/workspace/templates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tsugite-cli
3
- Version: 0.12.0
3
+ Version: 0.12.2
4
4
  Summary: Micro-agent runner for task automation using markdown definitions
5
5
  Author: Justyn Shull
6
6
  License: GNU AFFERO GENERAL PUBLIC LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tsugite-cli"
3
- version = "0.12.0"
3
+ version = "0.12.2"
4
4
  description = "Micro-agent runner for task automation using markdown definitions"
5
5
  authors = [{ name = "Justyn Shull" }]
6
6
  requires-python = ">=3.11"
@@ -207,6 +207,35 @@ async def test_agent_format_error_loop_bails_early():
207
207
  assert len(agent.memory.steps) <= 3
208
208
 
209
209
 
210
+ @pytest.mark.asyncio
211
+ async def test_agent_format_error_preserves_content_blocks_on_step():
212
+ """When the LLM emits content blocks but no code fence, the step should still carry them."""
213
+
214
+ agent = TsugiteAgent(
215
+ model_string="openai:gpt-4o-mini",
216
+ tools=[],
217
+ instructions="",
218
+ max_turns=5,
219
+ )
220
+
221
+ format_error = (
222
+ "Thought: writing without code\n\n" '<content name="reply">Stopped before creating anything.</content>'
223
+ )
224
+ _patch_provider(
225
+ agent,
226
+ side_effect=[
227
+ _mock_response(format_error),
228
+ _mock_response("```python\nfinal_answer('done')\n```"),
229
+ ],
230
+ )
231
+
232
+ await agent.run("Some task")
233
+
234
+ format_error_step = agent.memory.steps[0]
235
+ assert format_error_step.code == ""
236
+ assert format_error_step.content_blocks == {"reply": "Stopped before creating anything."}
237
+
238
+
210
239
  @pytest.mark.asyncio
211
240
  async def test_agent_format_error_resets_on_valid_code():
212
241
  """Test format error counter resets when model produces valid code."""
@@ -428,6 +457,54 @@ final_answer(42)
428
457
  assert "final_answer(42)" in parsed.code
429
458
 
430
459
 
460
+ @pytest.mark.asyncio
461
+ async def test_agent_parse_response_prose_only():
462
+ """Prose without a Thought: prefix or code block should be captured as thought."""
463
+ agent = TsugiteAgent(
464
+ model_string="openai:gpt-4o-mini",
465
+ tools=[],
466
+ instructions="",
467
+ max_turns=5,
468
+ )
469
+
470
+ parsed = agent._parse_response_from_text("You're welcome!")
471
+ assert parsed.thought == "You're welcome!"
472
+ assert parsed.code == ""
473
+
474
+
475
+ @pytest.mark.asyncio
476
+ async def test_agent_parse_response_prose_with_code_no_prefix():
477
+ """Prose preceding a code block (no Thought: prefix) should still be captured."""
478
+ agent = TsugiteAgent(
479
+ model_string="openai:gpt-4o-mini",
480
+ tools=[],
481
+ instructions="",
482
+ max_turns=5,
483
+ )
484
+
485
+ parsed = agent._parse_response_from_text(
486
+ "Sure thing.\n\n```python\nx = 1\nfinal_answer(x)\n```"
487
+ )
488
+ assert parsed.thought == "Sure thing."
489
+ assert "x = 1" in parsed.code
490
+ assert "final_answer(x)" in parsed.code
491
+
492
+
493
+ @pytest.mark.asyncio
494
+ async def test_agent_parse_response_code_only():
495
+ """A code-only response (no prose) should still yield empty thought."""
496
+ agent = TsugiteAgent(
497
+ model_string="openai:gpt-4o-mini",
498
+ tools=[],
499
+ instructions="",
500
+ max_turns=5,
501
+ )
502
+
503
+ parsed = agent._parse_response_from_text("```python\nfoo()\n```")
504
+ assert parsed.thought == ""
505
+ assert parsed.code == "foo()"
506
+
507
+
431
508
  @pytest.mark.asyncio
432
509
  async def test_agent_model_kwargs():
433
510
  """Test that model_kwargs are correctly filtered for reasoning models."""
@@ -675,9 +752,9 @@ def test_tool_execution_no_task_warnings():
675
752
  )
676
753
 
677
754
  assert "Task pending" not in filtered_stderr, f"Unexpected Task pending warning:\n{stderr_output}"
678
- assert "Task exception was never retrieved" not in filtered_stderr, (
679
- f"Unexpected Task exception warning:\n{stderr_output}"
680
- )
755
+ assert (
756
+ "Task exception was never retrieved" not in filtered_stderr
757
+ ), f"Unexpected Task exception warning:\n{stderr_output}"
681
758
 
682
759
 
683
760
  def test_tool_exception_propagation_from_async():
@@ -727,6 +804,6 @@ def test_tool_exception_propagation_from_async():
727
804
  line for line in stderr_output.split("\n") if "failing_tool" in line or "never retrieved" in line
728
805
  )
729
806
 
730
- assert "exception was never retrieved" not in filtered_stderr.lower(), (
731
- f"Exception handling broken:\n{stderr_output}"
732
- )
807
+ assert (
808
+ "exception was never retrieved" not in filtered_stderr.lower()
809
+ ), f"Exception handling broken:\n{stderr_output}"
@@ -408,6 +408,93 @@ class TestHistoryEndpoint:
408
408
  assert len(turn_data) == 1
409
409
  assert "reactions" not in turn_data[0]
410
410
 
411
+ def test_history_detail_attaches_content_blocks_per_message(self, client, test_token, mock_adapter, tmp_path):
412
+ """When detail=true, each assistant message carries its own content_blocks dict."""
413
+ from tsugite.history.storage import SessionStorage
414
+
415
+ session = mock_adapter.session_store.get_or_create_interactive("web-anonymous", "test-agent")
416
+ session_id = session.id
417
+ history_dir = tmp_path / "history"
418
+ history_dir.mkdir()
419
+ session_path = history_dir / f"{session_id}.jsonl"
420
+
421
+ storage = SessionStorage.create("test-agent", model="test", session_path=session_path)
422
+ storage.record_turn(
423
+ messages=[
424
+ {"role": "user", "content": "go"},
425
+ {"role": "assistant", "content": "```python\ninspect()\n```"},
426
+ {
427
+ "role": "user",
428
+ "content": '<tsugite_execution_result status="success" duration_ms="3"><output>ok</output></tsugite_execution_result>',
429
+ },
430
+ {
431
+ "role": "assistant",
432
+ "content": (
433
+ '```python\nfinal_answer(result="done")\n```\n\n'
434
+ '<content name="reply">Stopped before creating anything.</content>'
435
+ ),
436
+ },
437
+ ],
438
+ final_answer="done",
439
+ )
440
+ session_path.rename(history_dir / f"{session_id}.jsonl")
441
+
442
+ with patch("tsugite.daemon.adapters.http.get_history_dir", return_value=history_dir):
443
+ resp = client.get(
444
+ "/api/agents/test-agent/history?user_id=web-anonymous&detail=true",
445
+ headers={"Authorization": f"Bearer {test_token}"},
446
+ )
447
+
448
+ assert resp.status_code == 200
449
+ data = resp.json()
450
+ turn = next(t for t in data["turns"] if t.get("user"))
451
+
452
+ # Turn-level aggregate still available for backward compat
453
+ assert turn.get("content_blocks") == {"reply": "Stopped before creating anything."}
454
+
455
+ # Per-message attachment is the new source of truth for rendering
456
+ assistant_msgs = [m for m in turn["messages"] if m.get("role") == "assistant"]
457
+ assert assistant_msgs[0].get("content_blocks", {}) == {}
458
+ assert assistant_msgs[1]["content_blocks"] == {"reply": "Stopped before creating anything."}
459
+
460
+ def test_history_detail_preserves_execution_result_with_attributes(
461
+ self, client, test_token, mock_adapter, tmp_path
462
+ ):
463
+ """Execution-result tags with attributes must survive the payload unmodified."""
464
+ from tsugite.history.storage import SessionStorage
465
+
466
+ session = mock_adapter.session_store.get_or_create_interactive("web-anonymous", "test-agent")
467
+ session_id = session.id
468
+ history_dir = tmp_path / "history"
469
+ history_dir.mkdir()
470
+ session_path = history_dir / f"{session_id}.jsonl"
471
+
472
+ observation = (
473
+ '<tsugite_execution_result status="success" duration_ms="12">'
474
+ "<output>ran</output></tsugite_execution_result>"
475
+ )
476
+ storage = SessionStorage.create("test-agent", model="test", session_path=session_path)
477
+ storage.record_turn(
478
+ messages=[
479
+ {"role": "user", "content": "go"},
480
+ {"role": "assistant", "content": "```python\nprint('hi')\n```"},
481
+ {"role": "user", "content": observation},
482
+ ],
483
+ final_answer="done",
484
+ )
485
+ session_path.rename(history_dir / f"{session_id}.jsonl")
486
+
487
+ with patch("tsugite.daemon.adapters.http.get_history_dir", return_value=history_dir):
488
+ resp = client.get(
489
+ "/api/agents/test-agent/history?user_id=web-anonymous&detail=true",
490
+ headers={"Authorization": f"Bearer {test_token}"},
491
+ )
492
+
493
+ assert resp.status_code == 200
494
+ turn = next(t for t in resp.json()["turns"] if t.get("user"))
495
+ user_msgs = [m for m in turn["messages"] if m.get("role") == "user"]
496
+ assert any(m.get("content") == observation for m in user_msgs)
497
+
411
498
 
412
499
  class TestWebUI:
413
500
  def test_serve_ui(self, client):
@@ -0,0 +1,233 @@
1
+ """E2E tests for step-trace rendering: code truncation, content blocks, and tool results."""
2
+
3
+ from unittest.mock import patch
4
+
5
+ from tsugite.history.storage import SessionStorage
6
+
7
+
8
+ def _seed_isolated_turn(page, e2e_adapter, e2e_tmp, label, messages, final_answer="done"):
9
+ """Seed a fresh session (unique user) with one crafted turn and return (history_dir, user_id, session_id)."""
10
+ unique_user = f"web-user-{label}"
11
+ session = e2e_adapter.session_store.get_or_create_interactive(unique_user, "test-agent")
12
+ history_dir = e2e_tmp / f"history-{label}"
13
+ history_dir.mkdir(exist_ok=True)
14
+ session_path = history_dir / f"{session.id}.jsonl"
15
+
16
+ storage = SessionStorage.create("test-agent", model="test", session_path=session_path)
17
+ storage.record_turn(messages=messages, final_answer=final_answer)
18
+ return history_dir, unique_user, session.id
19
+
20
+
21
+ def _open_progress_trace(page, user_id, session_id):
22
+ """Reload as `user_id`, navigate to the session, and expand the progress-done summary."""
23
+ page.evaluate(f"localStorage.setItem('tsugite_user_id', {user_id!r})")
24
+ page.goto(page.url.split("#")[0] + f"#conversations?session={session_id}")
25
+ page.reload()
26
+ page.wait_for_function("!Alpine.store('app').authRequired", timeout=5000)
27
+ page.wait_for_function(f"Alpine.store('app').userId === {user_id!r}", timeout=3000)
28
+ page.wait_for_selector(".msg.user", timeout=5000)
29
+ page.wait_for_selector(".msg.progress", timeout=5000)
30
+ summary = page.locator(".msg.progress .tool-summary").first
31
+ summary.click()
32
+
33
+
34
+ def test_history_code_block_not_truncated(authenticated_page, e2e_adapter, e2e_tmp):
35
+ """Long code blocks must render in full on history reload, no trailing ellipsis."""
36
+ page = authenticated_page
37
+
38
+ long_code = "\n".join([f"line_{i} = 'x' * 80" for i in range(40)])
39
+ assistant_msg = f"```python\n{long_code}\n```"
40
+ history_dir, user_id, session_id = _seed_isolated_turn(
41
+ page,
42
+ e2e_adapter,
43
+ e2e_tmp,
44
+ "truncate",
45
+ messages=[
46
+ {"role": "user", "content": "go"},
47
+ {"role": "assistant", "content": assistant_msg},
48
+ ],
49
+ )
50
+
51
+ with patch("tsugite.daemon.adapters.http.get_history_dir", return_value=history_dir):
52
+ _open_progress_trace(page, user_id, session_id)
53
+ code_details = page.locator(".msg.progress .tool-steps li details").first
54
+ code_details.click()
55
+ code_text = page.locator(".msg.progress .tool-steps li details pre code").first.text_content()
56
+
57
+ assert "line_39 = 'x'" in code_text
58
+ assert not code_text.endswith("...")
59
+ assert len(code_text) >= len(long_code)
60
+
61
+
62
+ def test_history_content_block_renders_next_to_its_code(authenticated_page, e2e_adapter, e2e_tmp):
63
+ """Content blocks should appear inline with the assistant message that declared them."""
64
+ page = authenticated_page
65
+
66
+ history_dir, user_id, session_id = _seed_isolated_turn(
67
+ page,
68
+ e2e_adapter,
69
+ e2e_tmp,
70
+ "inline",
71
+ messages=[
72
+ {"role": "user", "content": "go"},
73
+ {"role": "assistant", "content": "```python\ninvestigate()\n```"},
74
+ {
75
+ "role": "user",
76
+ "content": '<tsugite_execution_result status="success"><output>ok</output></tsugite_execution_result>',
77
+ },
78
+ {
79
+ "role": "assistant",
80
+ "content": (
81
+ '```python\nfinal_answer(result="done")\n```\n\n'
82
+ '<content name="reply">Stopped before creating.</content>'
83
+ ),
84
+ },
85
+ ],
86
+ )
87
+
88
+ with patch("tsugite.daemon.adapters.http.get_history_dir", return_value=history_dir):
89
+ _open_progress_trace(page, user_id, session_id)
90
+
91
+ steps = page.locator(".msg.progress .tool-steps > li")
92
+ step_count = steps.count()
93
+ # Find the index of the content block and the 2nd code step
94
+ cb_index = None
95
+ code_indices = []
96
+ for i in range(step_count):
97
+ li = steps.nth(i)
98
+ if li.locator("details.content-block").count() > 0:
99
+ cb_index = i
100
+ elif "code" in (li.text_content() or ""):
101
+ code_indices.append(i)
102
+
103
+ assert cb_index is not None, "content block should be rendered"
104
+ assert len(code_indices) >= 2, "both code steps should render"
105
+ # content block came from the 2nd code step, so it must appear immediately after it,
106
+ # not pushed to the end of the trace
107
+ assert cb_index == code_indices[1] + 1
108
+
109
+
110
+ def test_history_tool_result_visible_between_code_steps(authenticated_page, e2e_adapter, e2e_tmp):
111
+ """tsugite_execution_result with attributes must render a tool_result step."""
112
+ page = authenticated_page
113
+
114
+ observation = (
115
+ '<tsugite_execution_result status="success" duration_ms="7">'
116
+ "<output>hello world</output></tsugite_execution_result>"
117
+ )
118
+ history_dir, user_id, session_id = _seed_isolated_turn(
119
+ page,
120
+ e2e_adapter,
121
+ e2e_tmp,
122
+ "toolresult",
123
+ messages=[
124
+ {"role": "user", "content": "go"},
125
+ {"role": "assistant", "content": "```python\nprint('hi')\n```"},
126
+ {"role": "user", "content": observation},
127
+ {"role": "assistant", "content": "```python\nfinal_answer('done')\n```"},
128
+ ],
129
+ )
130
+
131
+ with patch("tsugite.daemon.adapters.http.get_history_dir", return_value=history_dir):
132
+ _open_progress_trace(page, user_id, session_id)
133
+
134
+ summaries = page.locator(".msg.progress .tool-steps > li details > summary")
135
+ labels = [summaries.nth(i).text_content() or "" for i in range(summaries.count())]
136
+
137
+ # Two code steps plus a result step in between
138
+ code_hits = [i for i, s in enumerate(labels) if "code" in s]
139
+ result_hits = [i for i, s in enumerate(labels) if "result" in s]
140
+ assert len(code_hits) == 2
141
+ assert len(result_hits) == 1
142
+ assert code_hits[0] < result_hits[0] < code_hits[1]
143
+
144
+
145
+ def test_history_content_block_survives_no_code_turn(authenticated_page, e2e_adapter, e2e_tmp):
146
+ """A sub-turn that only emitted content blocks (no code) must still render them after reload."""
147
+ page = authenticated_page
148
+
149
+ history_dir, user_id, session_id = _seed_isolated_turn(
150
+ page,
151
+ e2e_adapter,
152
+ e2e_tmp,
153
+ "nocode",
154
+ messages=[
155
+ {"role": "user", "content": "go"},
156
+ {
157
+ "role": "assistant",
158
+ "content": '<content name="reply">Just a content block, no code.</content>',
159
+ },
160
+ {
161
+ "role": "user",
162
+ "content": '<tsugite_execution_result status="error"><error>no code</error></tsugite_execution_result>',
163
+ },
164
+ {"role": "assistant", "content": "```python\nfinal_answer('done')\n```"},
165
+ ],
166
+ )
167
+
168
+ with patch("tsugite.daemon.adapters.http.get_history_dir", return_value=history_dir):
169
+ _open_progress_trace(page, user_id, session_id)
170
+ content_blocks = page.locator(".msg.progress .tool-steps details.content-block")
171
+ assert content_blocks.count() == 1
172
+ content_blocks.first.click()
173
+ pre_text = page.locator(".msg.progress .tool-steps details.content-block pre code").first.text_content()
174
+ assert "Just a content block, no code." in pre_text
175
+
176
+
177
+ def test_history_prose_only_assistant_message_renders(authenticated_page, e2e_adapter, e2e_tmp):
178
+ """An assistant message that is prose only (no code block) must still render on reload.
179
+
180
+ Mirrors the real stored shape from _build_turn_messages: a prose-only step (thought as
181
+ assistant message), then the format-error observation, then the corrected final_answer
182
+ code step, its observation, and finally the plain-text result assistant message.
183
+ """
184
+ page = authenticated_page
185
+
186
+ history_dir, user_id, session_id = _seed_isolated_turn(
187
+ page,
188
+ e2e_adapter,
189
+ e2e_tmp,
190
+ "prose",
191
+ messages=[
192
+ {"role": "user", "content": "thanks"},
193
+ {"role": "assistant", "content": "You're welcome!"},
194
+ {
195
+ "role": "user",
196
+ "content": (
197
+ '<tsugite_execution_result status="error">'
198
+ "<error>Format Error: You must respond with a Python code block.</error>"
199
+ "</tsugite_execution_result>"
200
+ ),
201
+ },
202
+ {"role": "assistant", "content": '```python\nfinal_answer("You\'re welcome!")\n```'},
203
+ {
204
+ "role": "user",
205
+ "content": (
206
+ '<tsugite_execution_result status="success">'
207
+ "<output></output></tsugite_execution_result>"
208
+ ),
209
+ },
210
+ {"role": "assistant", "content": "You're welcome!"},
211
+ ],
212
+ final_answer="You're welcome!",
213
+ )
214
+
215
+ with patch("tsugite.daemon.adapters.http.get_history_dir", return_value=history_dir):
216
+ _open_progress_trace(page, user_id, session_id)
217
+
218
+ summaries = page.locator(".msg.progress .tool-steps > li details > summary")
219
+ labels = [summaries.nth(i).text_content() or "" for i in range(summaries.count())]
220
+ thought_hits = [i for i, s in enumerate(labels) if "thought" in s.lower()]
221
+ assert len(thought_hits) == 1, f"expected exactly one 'thought' step, got summaries: {labels}"
222
+
223
+ thought_idx = thought_hits[0]
224
+ result_hits = [i for i, s in enumerate(labels) if "result" in s.lower()]
225
+ assert result_hits, f"expected a result step, got summaries: {labels}"
226
+ assert thought_idx < result_hits[0], (
227
+ f"thought must appear before the format-error result, got: {labels}"
228
+ )
229
+
230
+ thought_details = page.locator(".msg.progress .tool-steps > li details").nth(thought_idx)
231
+ thought_details.click()
232
+ thought_text = thought_details.locator("pre code").first.text_content()
233
+ assert "You're welcome!" in thought_text
@@ -0,0 +1,205 @@
1
+ """E2E tests for markdown rendering in `.msg.agent` bubbles.
2
+
3
+ Written before swapping the hand-rolled renderer in `utils.js` for the `marked`
4
+ library. Regression cases pin pre-existing behavior; the table/alignment/style
5
+ cases drive the new behavior.
6
+ """
7
+
8
+ from unittest.mock import patch
9
+
10
+ from tsugite.history.storage import SessionStorage
11
+
12
+
13
+ def _seed_agent_turn(e2e_adapter, e2e_tmp, label, final_answer):
14
+ """Seed a fresh session whose single turn has the given markdown final_answer."""
15
+ unique_user = f"md-user-{label}"
16
+ session = e2e_adapter.session_store.get_or_create_interactive(unique_user, "test-agent")
17
+ history_dir = e2e_tmp / f"history-{label}"
18
+ history_dir.mkdir(exist_ok=True)
19
+ session_path = history_dir / f"{session.id}.jsonl"
20
+
21
+ storage = SessionStorage.create("test-agent", model="test", session_path=session_path)
22
+ storage.record_turn(
23
+ messages=[{"role": "user", "content": "show"}],
24
+ final_answer=final_answer,
25
+ )
26
+ return history_dir, unique_user, session.id
27
+
28
+
29
+ def _open_session(page, user_id, session_id):
30
+ page.evaluate(f"localStorage.setItem('tsugite_user_id', {user_id!r})")
31
+ page.goto(page.url.split("#")[0] + f"#conversations?session={session_id}")
32
+ page.reload()
33
+ page.wait_for_function("!Alpine.store('app').authRequired", timeout=5000)
34
+ page.wait_for_function(f"Alpine.store('app').userId === {user_id!r}", timeout=3000)
35
+ page.wait_for_selector(".msg.agent", timeout=5000)
36
+
37
+
38
+ def test_markdown_regression_basic_formatting(authenticated_page, e2e_adapter, e2e_tmp):
39
+ """All pre-existing markdown features still render after the parser swap."""
40
+ page = authenticated_page
41
+
42
+ md = (
43
+ "# H1\n"
44
+ "## H2\n"
45
+ "### H3\n"
46
+ "#### H4\n\n"
47
+ "A paragraph with **bold**, *italic*, and `inline`.\n\n"
48
+ "- ul item 1\n"
49
+ "- ul item 2\n\n"
50
+ "1. ol item 1\n"
51
+ "2. ol item 2\n\n"
52
+ "> a blockquote\n\n"
53
+ "---\n\n"
54
+ "A [link](https://example.com).\n\n"
55
+ "```python\nprint('hello')\n```\n"
56
+ )
57
+
58
+ history_dir, user_id, session_id = _seed_agent_turn(e2e_adapter, e2e_tmp, "regression", md)
59
+
60
+ with patch("tsugite.daemon.adapters.http.get_history_dir", return_value=history_dir):
61
+ _open_session(page, user_id, session_id)
62
+ agent = page.locator(".msg.agent").last
63
+
64
+ assert agent.locator("h1").count() >= 1
65
+ assert agent.locator("h2").count() >= 1
66
+ assert agent.locator("h3").count() >= 1
67
+ assert agent.locator("h4").count() >= 1
68
+
69
+ assert agent.locator("strong").first.text_content() == "bold"
70
+ assert agent.locator("em").first.text_content() == "italic"
71
+ assert agent.locator("code").first.text_content() == "inline"
72
+
73
+ assert agent.locator("ul > li").count() == 2
74
+ assert agent.locator("ol > li").count() == 2
75
+
76
+ assert agent.locator("blockquote").count() == 1
77
+ assert agent.locator("hr").count() == 1
78
+
79
+ link = agent.locator("a").first
80
+ assert link.get_attribute("href") == "https://example.com"
81
+
82
+ pre_code = agent.locator("pre code")
83
+ assert pre_code.count() == 1
84
+ assert "print('hello')" in (pre_code.first.text_content() or "")
85
+
86
+
87
+ def test_markdown_gfm_table_renders(authenticated_page, e2e_adapter, e2e_tmp):
88
+ """Simple GFM table produces <table><thead><th>...<tbody><tr><td>."""
89
+ page = authenticated_page
90
+
91
+ md = (
92
+ "| Name | Score |\n"
93
+ "| ---- | ----- |\n"
94
+ "| Alice | 10 |\n"
95
+ "| Bob | 20 |\n"
96
+ )
97
+
98
+ history_dir, user_id, session_id = _seed_agent_turn(e2e_adapter, e2e_tmp, "table", md)
99
+
100
+ with patch("tsugite.daemon.adapters.http.get_history_dir", return_value=history_dir):
101
+ _open_session(page, user_id, session_id)
102
+ agent = page.locator(".msg.agent").last
103
+
104
+ assert agent.locator("table").count() == 1
105
+ assert agent.locator("table thead th").count() == 2
106
+ assert agent.locator("table tbody tr").count() == 2
107
+ assert (agent.locator("table thead th").first.text_content() or "").strip() == "Name"
108
+ last_cell = agent.locator("table tbody tr").nth(1).locator("td").last
109
+ assert (last_cell.text_content() or "").strip() == "20"
110
+
111
+
112
+ def test_markdown_gfm_table_alignment(authenticated_page, e2e_adapter, e2e_tmp):
113
+ """GFM alignment syntax yields text-align on th/td via inline style."""
114
+ page = authenticated_page
115
+
116
+ md = (
117
+ "| L | C | R |\n"
118
+ "| :--- | :---: | ---: |\n"
119
+ "| a | b | c |\n"
120
+ )
121
+
122
+ history_dir, user_id, session_id = _seed_agent_turn(e2e_adapter, e2e_tmp, "align", md)
123
+
124
+ with patch("tsugite.daemon.adapters.http.get_history_dir", return_value=history_dir):
125
+ _open_session(page, user_id, session_id)
126
+ agent = page.locator(".msg.agent").last
127
+
128
+ ths = agent.locator("table thead th")
129
+ tds = agent.locator("table tbody tr").first.locator("td")
130
+
131
+ def _align(loc, i):
132
+ return loc.nth(i).evaluate("el => getComputedStyle(el).textAlign")
133
+
134
+ assert _align(ths, 0) in ("left", "start", "-webkit-left")
135
+ assert _align(ths, 1) in ("center", "-webkit-center")
136
+ assert _align(ths, 2) in ("right", "end", "-webkit-right")
137
+ assert _align(tds, 0) in ("left", "start", "-webkit-left")
138
+ assert _align(tds, 1) in ("center", "-webkit-center")
139
+ assert _align(tds, 2) in ("right", "end", "-webkit-right")
140
+
141
+
142
+ def test_markdown_table_styling(authenticated_page, e2e_adapter, e2e_tmp):
143
+ """Computed CSS matches the design: no uppercase header, last row no border."""
144
+ page = authenticated_page
145
+
146
+ md = (
147
+ "| h1 | h2 |\n"
148
+ "| --- | --- |\n"
149
+ "| a | b |\n"
150
+ "| c | d |\n"
151
+ )
152
+
153
+ history_dir, user_id, session_id = _seed_agent_turn(e2e_adapter, e2e_tmp, "style", md)
154
+
155
+ with patch("tsugite.daemon.adapters.http.get_history_dir", return_value=history_dir):
156
+ _open_session(page, user_id, session_id)
157
+ agent = page.locator(".msg.agent").last
158
+
159
+ th = agent.locator("table th").first
160
+ assert th.evaluate("el => getComputedStyle(el).textTransform") == "none"
161
+ weight = th.evaluate("el => getComputedStyle(el).fontWeight")
162
+ assert weight in ("600", "700", "bold"), f"unexpected th font-weight: {weight}"
163
+
164
+ last_td = agent.locator("table tbody tr").last.locator("td").first
165
+ border = last_td.evaluate(
166
+ "el => ({ style: getComputedStyle(el).borderBottomStyle, "
167
+ "width: getComputedStyle(el).borderBottomWidth })"
168
+ )
169
+ assert border["style"] == "none" or border["width"] == "0px", (
170
+ f"last row should have no bottom border; got {border}"
171
+ )
172
+
173
+
174
+ def test_markdown_wide_table_scrolls_inside_bubble(authenticated_page, e2e_adapter, e2e_tmp):
175
+ """A wide table scrolls inside the bubble; the page itself does not overflow."""
176
+ page = authenticated_page
177
+
178
+ cols = 8
179
+ header = "| " + " | ".join(f"col{i}" for i in range(cols)) + " |\n"
180
+ sep = "| " + " | ".join("---" for _ in range(cols)) + " |\n"
181
+ row = "| " + " | ".join("very long cell content " * 3 for _ in range(cols)) + " |\n"
182
+ md = header + sep + row + row
183
+
184
+ history_dir, user_id, session_id = _seed_agent_turn(e2e_adapter, e2e_tmp, "wide-table", md)
185
+
186
+ page.set_viewport_size({"width": 400, "height": 800})
187
+
188
+ with patch("tsugite.daemon.adapters.http.get_history_dir", return_value=history_dir):
189
+ _open_session(page, user_id, session_id)
190
+ table = page.locator(".msg.agent table").last
191
+
192
+ dims = table.evaluate(
193
+ "el => ({ scrollWidth: el.scrollWidth, clientWidth: el.clientWidth })"
194
+ )
195
+ assert dims["scrollWidth"] > dims["clientWidth"], (
196
+ f"expected table to be horizontally scrollable; got {dims}"
197
+ )
198
+
199
+ page_dims = page.evaluate(
200
+ "() => ({ scrollWidth: document.documentElement.scrollWidth, "
201
+ "clientWidth: document.documentElement.clientWidth })"
202
+ )
203
+ assert page_dims["scrollWidth"] <= page_dims["clientWidth"] + 1, (
204
+ f"page itself should not horizontally scroll; got {page_dims}"
205
+ )