kiwi-code 0.0.501__tar.gz → 0.0.503__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 (123) hide show
  1. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_cli/client.py +101 -2
  4. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_cli/commands.py +1 -0
  5. kiwi_code-0.0.503/src/kiwi_cli/human_questions.py +200 -0
  6. kiwi_code-0.0.503/src/kiwi_cli/subagents.py +383 -0
  7. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/main.py +13 -2
  8. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/screens/term_dashboard.py +759 -49
  9. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/screens/term_dashboard_css.py +170 -1
  10. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/screens/term_dashboard_helpers.py +23 -0
  11. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/screens/term_dashboard_widgets.py +5 -1
  12. kiwi_code-0.0.503/src/kiwi_tui/screens/term_question_widgets.py +567 -0
  13. kiwi_code-0.0.503/src/kiwi_tui/screens/term_subagent_widgets.py +799 -0
  14. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/slash_commands.py +1 -0
  15. kiwi_code-0.0.503/tests/test_human_questions.py +636 -0
  16. kiwi_code-0.0.503/tests/test_subagents.py +463 -0
  17. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_term_dashboard_ui.py +141 -6
  18. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_tui_palette.py +12 -1
  19. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/uv.lock +1 -1
  20. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/.github/workflows/publish.yml +0 -0
  21. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/.github/workflows/test.yml +0 -0
  22. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/.gitignore +0 -0
  23. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/.python-version +0 -0
  24. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/Makefile +0 -0
  25. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/README.md +0 -0
  26. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/docs/runtime_audit.html +0 -0
  27. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/docs/social_login_e2e.md +0 -0
  28. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/scripts/set_edition.py +0 -0
  29. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/scripts/ws_auth_check.py +0 -0
  30. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/common/__init__.py +0 -0
  31. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/common/banner.py +0 -0
  32. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_cli/__init__.py +0 -0
  33. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_cli/_edition.py +0 -0
  34. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_cli/auth.py +0 -0
  35. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_cli/autocode.py +0 -0
  36. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_cli/checkpoints.py +0 -0
  37. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_cli/cli.py +0 -0
  38. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_cli/edition.py +0 -0
  39. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_cli/logger.py +0 -0
  40. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_cli/models.py +0 -0
  41. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_cli/oauth.py +0 -0
  42. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_cli/runtime_manager.py +0 -0
  43. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_cli/server.py +0 -0
  44. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_cli/terminal_mode.py +0 -0
  45. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/__init__.py +0 -0
  46. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/__main__.py +0 -0
  47. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/auth.py +0 -0
  48. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/checkpoints.py +0 -0
  49. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/cli.py +0 -0
  50. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/commands.py +0 -0
  51. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/constants.py +0 -0
  52. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/__init__.py +0 -0
  53. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/batch.py +0 -0
  54. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/codelens.py +0 -0
  55. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/context.py +0 -0
  56. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/handler.py +0 -0
  57. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/paths.py +0 -0
  58. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/read.py +0 -0
  59. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/search.py +0 -0
  60. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/text.py +0 -0
  61. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/upload.py +0 -0
  62. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/fs/write.py +0 -0
  63. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/handlers.py +0 -0
  64. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/link.py +0 -0
  65. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/main.py +0 -0
  66. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/restricted.py +0 -0
  67. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/sessions.py +0 -0
  68. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/state.py +0 -0
  69. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/tui_bridge.py +0 -0
  70. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/ui.py +0 -0
  71. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_runtime/uploads.py +0 -0
  72. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/__init__.py +0 -0
  73. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/inline_file_picker.py +0 -0
  74. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/random_words.py +0 -0
  75. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/role_mode.py +0 -0
  76. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/runtime_agent.py +0 -0
  77. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/screens/__init__.py +0 -0
  78. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/screens/attach_content.py +0 -0
  79. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/screens/command_result.py +0 -0
  80. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/screens/detach_files.py +0 -0
  81. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/screens/file_browser.py +0 -0
  82. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/screens/help.py +0 -0
  83. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/screens/id_picker.py +0 -0
  84. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/screens/login.py +0 -0
  85. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  86. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  87. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/screens/slash_picker.py +0 -0
  88. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/status_words.py +0 -0
  89. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/term_app.py +0 -0
  90. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/ui_settings.py +0 -0
  91. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/widgets.py +0 -0
  92. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/src/kiwi_tui/worktrees.py +0 -0
  93. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/test_hello.py +0 -0
  94. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/__init__.py +0 -0
  95. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/conftest.py +0 -0
  96. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_account_server.py +0 -0
  97. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_autocode_actions.py +0 -0
  98. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_batch_fs_tool.py +0 -0
  99. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_checkpoints.py +0 -0
  100. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_cli_help.py +0 -0
  101. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_copy_path_fs_tool.py +0 -0
  102. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_fs_edit_fidelity.py +0 -0
  103. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_imports.py +0 -0
  104. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_login_social.py +0 -0
  105. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_oauth.py +0 -0
  106. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_read_file_streaming.py +0 -0
  107. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_reexec_kiwi.py +0 -0
  108. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_role_mode_injection.py +0 -0
  109. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_run_steering.py +0 -0
  110. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_runtime_cleanup_per_account.py +0 -0
  111. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_runtime_connection.py +0 -0
  112. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_runtime_fixes.py +0 -0
  113. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_runtime_imports_on_windows.py +0 -0
  114. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_runtime_log_trimming.py +0 -0
  115. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_runtime_social_login.py +0 -0
  116. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_search_in_files_fs_tool.py +0 -0
  117. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_slash_commands.py +0 -0
  118. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_stats_command.py +0 -0
  119. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_terminal_mode.py +0 -0
  120. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_tokens.py +0 -0
  121. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_tui_interactive_runtime.py +0 -0
  122. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_unified_diff.py +0 -0
  123. {kiwi_code-0.0.501 → kiwi_code-0.0.503}/tests/test_worktrees.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: kiwi-code
3
- Version: 0.0.501
3
+ Version: 0.0.503
4
4
  Summary: A textual-based terminal user interface application
5
5
  Project-URL: Homepage, https://kiwicode.ai
6
6
  Author-email: Anurag <anurag@meetkiwi.co>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiwi-code"
3
- version = "0.0.501"
3
+ version = "0.0.503"
4
4
  description = "A textual-based terminal user interface application"
5
5
  readme = {file = "README.md", content-type = "text/markdown"}
6
6
  requires-python = ">=3.11,<4.0"
@@ -11,8 +11,13 @@ from pykiwiai.api.hello import hello_v1_hello_get
11
11
  from pykiwiai.api.auth import (
12
12
  return_user_and_session_v1_auth_post,
13
13
  )
14
- from pykiwiai.api.actions import async_run_action_v1_actions_id_async_run_post
14
+ from pykiwiai.api.actions import (
15
+ answer_human_questions_v1_actions_action_result_id_answer_post,
16
+ async_run_action_v1_actions_id_async_run_post,
17
+ )
15
18
  from pykiwiai.api.action_results import get_action_result_v1_action_results_id_get
19
+ from pykiwiai.api.subagents import list_subagents_of_run_v1_subagents_runs_parent_action_run_id_get
20
+ from pykiwiai.models.human_answers_request import HumanAnswersRequest
16
21
  from pykiwiai.models.body_return_user_and_session_v1_auth_post import (
17
22
  BodyReturnUserAndSessionV1AuthPost,
18
23
  )
@@ -21,6 +26,7 @@ from pykiwiai.models.body_async_run_action_v1_actions_id_async_run_post import (
21
26
  )
22
27
  from pykiwiai.models import DataBlock, Metadata
23
28
  from .models import AuthTokens, LoginCredentials
29
+ from .human_questions import WAITING_STATUS, human_event_type, parse_questions
24
30
 
25
31
 
26
32
  class AutobotsClientWrapper:
@@ -545,6 +551,11 @@ class AutobotsClientWrapper:
545
551
  except Exception:
546
552
  pass
547
553
 
554
+ questions = getattr(result, "human_questions", None)
555
+ result_dict["human_questions"] = parse_questions(
556
+ [q.to_dict() for q in questions] if isinstance(questions, list) else None
557
+ )
558
+
548
559
  result_dict["id"] = run_id
549
560
 
550
561
  # Prompts waiting behind the current turn: with any left, a finished turn
@@ -604,6 +615,10 @@ class AutobotsClientWrapper:
604
615
  elif status == "cancelled":
605
616
  return False, result, "Action cancelled"
606
617
 
618
+ # The turn ended asking the user something: done until they answer.
619
+ elif status == WAITING_STATUS:
620
+ return True, result, "Action waiting for an answer"
621
+
607
622
  # Still processing (a run being cancelled is not over until it says so)
608
623
  elif status in ["processing", "running", "pending", "request_cancel"]:
609
624
  logger.debug(f"Action still {status}, attempt {attempt + 1}/{max_attempts}")
@@ -684,7 +699,12 @@ class AutobotsClientWrapper:
684
699
  try:
685
700
  data = json.loads(data_str)
686
701
  on_message(data)
687
- if stop_on_terminal_status and isinstance(data, dict):
702
+ # A question event's `status` is the questions', not the run's.
703
+ if (
704
+ stop_on_terminal_status
705
+ and isinstance(data, dict)
706
+ and human_event_type(data) is None
707
+ ):
688
708
  status = str(data.get("status", "") or "").lower()
689
709
  if status in ["completed", "success", "finished", "failed", "error", "cancelled"]:
690
710
  return True
@@ -751,6 +771,85 @@ class AutobotsClientWrapper:
751
771
  logger.error(f"SSE error: {e}")
752
772
  raise
753
773
 
774
+ def answer_human_questions(
775
+ self, run_id: str, answers: list[dict]
776
+ ) -> tuple[int, Optional[dict], str]:
777
+ """Answer questions the run's model asked with `ask_human`.
778
+
779
+ `POST /v1/actions/{run_id}/answer`. Every open question must be in `answers`, each
780
+ answered (`selected` and/or `text`) or skipped (`skip: true`); recorded all or nothing.
781
+
782
+ Returns:
783
+ Tuple of (http_status, body, message). On 200 the body has `question_ids` and
784
+ `delivered_as`: "turn" when the run had finished and reopened as processing,
785
+ "instruct" when it was still running. 0 means the request did not complete.
786
+ """
787
+ try:
788
+ if not isinstance(self.client, AuthenticatedClient):
789
+ return 401, None, "Authentication required"
790
+ response = answer_human_questions_v1_actions_action_result_id_answer_post.sync_detailed(
791
+ action_result_id=run_id,
792
+ client=self.client,
793
+ body=HumanAnswersRequest.from_dict({"answers": answers}),
794
+ )
795
+ code = int(response.status_code)
796
+ if code == 200 and response.parsed is not None and hasattr(response.parsed, "delivered_as"):
797
+ body = response.parsed.to_dict()
798
+ logger.info(f"Answered {len(answers)} question(s) on run {run_id}: {body}")
799
+ return 200, body, "Answer sent"
800
+ # Only 200 and 422 are typed; the 400/403/404/409 `detail` is read from the body.
801
+ try:
802
+ body = json.loads(response.content or b"null")
803
+ except ValueError:
804
+ body = None
805
+ detail = body.get("detail") if isinstance(body, dict) else None
806
+ if not isinstance(detail, str):
807
+ detail = json.dumps(detail) if detail else (response.content or b"").decode("utf-8", "replace")
808
+ message = f"HTTP {code}" + (f": {detail}" if detail else "")
809
+ logger.warning(f"Answering questions on run {run_id} failed: {message}")
810
+ return code, body if isinstance(body, dict) else None, message
811
+ except Exception as e:
812
+ logger.error(f"Error answering questions on run {run_id}: {e}")
813
+ return 0, None, f"Error sending answer: {e}"
814
+
815
+ def list_subagents(self, run_id: str) -> tuple[int, Optional[dict], str]:
816
+ """The subagents `run_id` has spawned, once each with its status:
817
+ `GET /v1/subagents/runs/{run_id}`.
818
+
819
+ Returns:
820
+ Tuple of (http_status, body, message); body is the `SubagentsOfRun` dict on 200.
821
+ """
822
+ try:
823
+ if not isinstance(self.client, AuthenticatedClient):
824
+ return 401, None, "Authentication required"
825
+ response = list_subagents_of_run_v1_subagents_runs_parent_action_run_id_get.sync_detailed(
826
+ parent_action_run_id=run_id, client=self.client
827
+ )
828
+ code = int(response.status_code)
829
+ if code == 200 and response.parsed is not None and hasattr(response.parsed, "subagents"):
830
+ return 200, response.parsed.to_dict(), "ok"
831
+ return code, None, f"HTTP {code}"
832
+ except Exception as e:
833
+ logger.warning(f"Listing subagents of run {run_id} failed: {e}")
834
+ return 0, None, f"Error listing subagents: {e}"
835
+
836
+ def get_run_activity(self, run_id: str) -> tuple[int, list[dict]]:
837
+ """A run's recent typed activity events, oldest first
838
+ (`GET /v1/server_sent_events/activity/{run_id}`), for a client that subscribes to
839
+ `{run_id}-activity` late. A backend without the activity stream answers 404.
840
+ """
841
+ try:
842
+ if not isinstance(self.client, AuthenticatedClient):
843
+ return 401, []
844
+ response = self.client.get_httpx_client().get(f"/v1/server_sent_events/activity/{run_id}")
845
+ if response.status_code != 200:
846
+ return response.status_code, []
847
+ events = (response.json() or {}).get("events")
848
+ return 200, [e for e in events if isinstance(e, dict)] if isinstance(events, list) else []
849
+ except Exception as e:
850
+ logger.warning(f"Reading activity of run {run_id} failed: {e}")
851
+ return 0, []
852
+
754
853
  def close(self) -> None:
755
854
  """Close the client connection."""
756
855
  try:
@@ -770,6 +770,7 @@ Session:
770
770
  /queue remove <n> Drop one waiting prompt, by its number in /queue list
771
771
  /queue clear Drop every prompt waiting to run
772
772
  /stats [7d|30d|90d|1y] Show run statistics: runs, tokens, spend (default: 7d)
773
+ /agents Show this run's subagents (also ctrl+g)
773
774
  /usage [run_id] Show token usage for a run and its subagents (default: current run)
774
775
 
775
776
  Files:
@@ -0,0 +1,200 @@
1
+ """Questions an agent asks the user mid-run with the `ask_human` tool.
2
+
3
+ The run document is the source of truth: `human_questions` on
4
+ `GET /v1/action_results/{run_id}` holds every question ever asked in the run, each
5
+ pointing at the turn it was asked in (`turn_index`, an index into `result.results`).
6
+ The three events on the run's SSE topic only signal that the list changed; they are
7
+ delivered at most once, so a client refetches the run on each one.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Iterable
13
+
14
+ ASK_HUMAN_EVENT = "ask_human"
15
+ HUMAN_ANSWERED_EVENT = "human_answered"
16
+ HUMAN_QUESTIONS_CLOSED_EVENT = "human_questions_closed"
17
+ HUMAN_EVENT_TYPES = frozenset({ASK_HUMAN_EVENT, HUMAN_ANSWERED_EVENT, HUMAN_QUESTIONS_CLOSED_EVENT})
18
+
19
+ #: `result.results[i].input.source` of a turn the backend opened with the user's answers.
20
+ #: The cards already show the answers, so that turn's input is not drawn as a user message.
21
+ HUMAN_ANSWER_SOURCE = "human_answer"
22
+
23
+ #: Action-run status of a run whose turn ended with an unanswered question. The run is
24
+ #: finished as far as a client is concerned: stop polling and let the user type.
25
+ WAITING_STATUS = "waiting"
26
+
27
+ PENDING = "pending"
28
+ ANSWERED = "answered"
29
+ SKIPPED = "skipped"
30
+ CANCELLED = "cancelled"
31
+
32
+
33
+ def human_event_type(data: object) -> str | None:
34
+ """The event type when `data` is one of the three human-question events."""
35
+ if isinstance(data, dict):
36
+ t = data.get("type")
37
+ if t in HUMAN_EVENT_TYPES:
38
+ return str(t)
39
+ return None
40
+
41
+
42
+ def _question(raw: object) -> dict[str, Any] | None:
43
+ if not isinstance(raw, dict) or not raw.get("question_id"):
44
+ return None
45
+ options = []
46
+ for opt in raw.get("options") or []:
47
+ if isinstance(opt, dict) and opt.get("label"):
48
+ options.append({"label": str(opt["label"]), "description": str(opt.get("description") or "")})
49
+ answer = raw.get("answer")
50
+ if isinstance(answer, dict):
51
+ answer = {
52
+ "selected": [str(s) for s in (answer.get("selected") or [])],
53
+ "text": answer.get("text"),
54
+ "skipped": bool(answer.get("skipped")),
55
+ "answered_by": answer.get("answered_by"),
56
+ "answered_at": answer.get("answered_at"),
57
+ }
58
+ else:
59
+ answer = None
60
+ try:
61
+ turn_index = int(raw.get("turn_index") or 0)
62
+ except (TypeError, ValueError):
63
+ turn_index = 0
64
+ return {
65
+ "question_id": str(raw["question_id"]),
66
+ "turn_index": turn_index,
67
+ "header": str(raw.get("header") or ""),
68
+ "question": str(raw.get("question") or ""),
69
+ "options": options,
70
+ "multi_select": bool(raw.get("multi_select")),
71
+ "status": str(raw.get("status") or PENDING),
72
+ "asked_at": raw.get("asked_at"),
73
+ "answer": answer,
74
+ }
75
+
76
+
77
+ def parse_questions(raw: object) -> list[dict[str, Any]]:
78
+ """Normalise a `human_questions` list (from the run or an `ask_human` event)."""
79
+ if not isinstance(raw, list):
80
+ return []
81
+ return [q for q in (_question(item) for item in raw) if q is not None]
82
+
83
+
84
+ def questions_by_turn(questions: Iterable[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]:
85
+ """Group questions by `turn_index`, keeping the order they were asked in."""
86
+ grouped: dict[int, list[dict[str, Any]]] = {}
87
+ for q in questions:
88
+ grouped.setdefault(int(q.get("turn_index") or 0), []).append(q)
89
+ return grouped
90
+
91
+
92
+ def is_answer_turn(item: object) -> bool:
93
+ """Whether a `result.results[i]` entry is a turn opened by the user's answers."""
94
+ if not isinstance(item, dict):
95
+ return False
96
+ inp = item.get("input")
97
+ return isinstance(inp, dict) and inp.get("source") == HUMAN_ANSWER_SOURCE
98
+
99
+
100
+ def has_pending(questions: Iterable[dict[str, Any]]) -> bool:
101
+ return any(q.get("status") == PENDING for q in questions)
102
+
103
+
104
+ @dataclass
105
+ class QuestionDraft:
106
+ """What the user has picked or typed on one card, before submitting."""
107
+
108
+ selected: list[str] = field(default_factory=list)
109
+ text: str = ""
110
+ #: The user pressed Skip on the card: sent as `skip: true`, without choices or text.
111
+ skipped: bool = False
112
+
113
+ def is_filled(self) -> bool:
114
+ return bool(self.selected) or bool(self.text.strip())
115
+
116
+ def is_handled(self) -> bool:
117
+ return self.skipped or self.is_filled()
118
+
119
+
120
+ def draft_is_valid(question: dict[str, Any], draft: QuestionDraft | None) -> bool:
121
+ """A draft the backend would accept, or one not handled yet."""
122
+ if draft is None or draft.skipped or not draft.is_filled():
123
+ return True
124
+ labels = {o["label"] for o in question.get("options") or []}
125
+ if any(s not in labels for s in draft.selected):
126
+ return False
127
+ if not question.get("multi_select") and len(draft.selected) > 1:
128
+ return False
129
+ return True
130
+
131
+
132
+ def build_answers(
133
+ questions: Iterable[dict[str, Any]], drafts: dict[str, QuestionDraft]
134
+ ) -> list[dict[str, Any]]:
135
+ """The `answers` body of `POST /actions/{run_id}/answer`: one item per pending card, in
136
+ card order, answered or `skip: true`. The backend refuses a call that leaves any open
137
+ question out, so `can_submit` must hold first."""
138
+ answers: list[dict[str, Any]] = []
139
+ for q in questions:
140
+ if q.get("status") != PENDING:
141
+ continue
142
+ draft = drafts.get(q["question_id"])
143
+ if draft is not None and draft.skipped:
144
+ answers.append({"question_id": q["question_id"], "skip": True})
145
+ continue
146
+ if draft is None or not draft.is_filled():
147
+ continue
148
+ item: dict[str, Any] = {"question_id": q["question_id"], "selected": list(draft.selected)}
149
+ text = draft.text.strip()
150
+ if text:
151
+ item["text"] = text
152
+ answers.append(item)
153
+ return answers
154
+
155
+
156
+ def can_submit(questions: Iterable[dict[str, Any]], drafts: dict[str, QuestionDraft]) -> bool:
157
+ """Every pending card is answered (validly) or skipped: one call handles them all."""
158
+ pending = [q for q in questions if q.get("status") == PENDING]
159
+ if not pending:
160
+ return False
161
+ for q in pending:
162
+ draft = drafts.get(q["question_id"])
163
+ if draft is None or not draft.is_handled() or not draft_is_valid(q, draft):
164
+ return False
165
+ return True
166
+
167
+
168
+ def with_status(
169
+ questions: Iterable[dict[str, Any]],
170
+ status: str,
171
+ *,
172
+ question_ids: Iterable[str] | None = None,
173
+ answers: Iterable[dict[str, Any]] | None = None,
174
+ ) -> list[dict[str, Any]]:
175
+ """Copies of `questions` with the pending ones (or only `question_ids`) set to `status`;
176
+ a question whose answer item is a skip becomes `skipped` instead.
177
+
178
+ Used to update the screen before the backend's copy arrives; the next fetch of the run
179
+ replaces it.
180
+ """
181
+ ids = set(question_ids) if question_ids is not None else None
182
+ by_id = {a["question_id"]: a for a in (answers or [])}
183
+ out = []
184
+ for q in questions:
185
+ q = dict(q)
186
+ if q.get("status") == PENDING and (ids is None or q["question_id"] in ids):
187
+ q["status"] = status
188
+ a = by_id.get(q["question_id"])
189
+ if a is not None:
190
+ if a.get("skip"):
191
+ q["status"] = SKIPPED
192
+ q["answer"] = {
193
+ "selected": list(a.get("selected") or []),
194
+ "text": a.get("text"),
195
+ "skipped": bool(a.get("skip")),
196
+ "answered_by": None,
197
+ "answered_at": None,
198
+ }
199
+ out.append(q)
200
+ return out