kiwi-code 0.0.501__tar.gz → 0.0.502__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 (120) hide show
  1. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/client.py +62 -2
  4. kiwi_code-0.0.502/src/kiwi_cli/human_questions.py +200 -0
  5. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/term_dashboard.py +372 -37
  6. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/term_dashboard_css.py +132 -1
  7. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/term_dashboard_widgets.py +5 -1
  8. kiwi_code-0.0.502/src/kiwi_tui/screens/term_question_widgets.py +567 -0
  9. kiwi_code-0.0.502/tests/test_human_questions.py +636 -0
  10. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/uv.lock +1 -1
  11. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/.github/workflows/publish.yml +0 -0
  12. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/.github/workflows/test.yml +0 -0
  13. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/.gitignore +0 -0
  14. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/.python-version +0 -0
  15. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/Makefile +0 -0
  16. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/README.md +0 -0
  17. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/docs/runtime_audit.html +0 -0
  18. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/docs/social_login_e2e.md +0 -0
  19. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/scripts/set_edition.py +0 -0
  20. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/scripts/ws_auth_check.py +0 -0
  21. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/common/__init__.py +0 -0
  22. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/common/banner.py +0 -0
  23. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/__init__.py +0 -0
  24. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/_edition.py +0 -0
  25. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/auth.py +0 -0
  26. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/autocode.py +0 -0
  27. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/checkpoints.py +0 -0
  28. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/cli.py +0 -0
  29. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/commands.py +0 -0
  30. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/edition.py +0 -0
  31. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/logger.py +0 -0
  32. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/models.py +0 -0
  33. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/oauth.py +0 -0
  34. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/runtime_manager.py +0 -0
  35. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/server.py +0 -0
  36. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_cli/terminal_mode.py +0 -0
  37. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/__init__.py +0 -0
  38. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/__main__.py +0 -0
  39. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/auth.py +0 -0
  40. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/checkpoints.py +0 -0
  41. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/cli.py +0 -0
  42. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/commands.py +0 -0
  43. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/constants.py +0 -0
  44. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/__init__.py +0 -0
  45. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/batch.py +0 -0
  46. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/codelens.py +0 -0
  47. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/context.py +0 -0
  48. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/handler.py +0 -0
  49. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/paths.py +0 -0
  50. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/read.py +0 -0
  51. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/search.py +0 -0
  52. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/text.py +0 -0
  53. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/upload.py +0 -0
  54. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/write.py +0 -0
  55. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/handlers.py +0 -0
  56. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/link.py +0 -0
  57. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/main.py +0 -0
  58. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/restricted.py +0 -0
  59. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/sessions.py +0 -0
  60. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/state.py +0 -0
  61. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/tui_bridge.py +0 -0
  62. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/ui.py +0 -0
  63. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_runtime/uploads.py +0 -0
  64. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/__init__.py +0 -0
  65. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/inline_file_picker.py +0 -0
  66. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/main.py +0 -0
  67. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/random_words.py +0 -0
  68. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/role_mode.py +0 -0
  69. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/runtime_agent.py +0 -0
  70. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/__init__.py +0 -0
  71. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/attach_content.py +0 -0
  72. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/command_result.py +0 -0
  73. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/detach_files.py +0 -0
  74. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/file_browser.py +0 -0
  75. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/help.py +0 -0
  76. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/id_picker.py +0 -0
  77. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/login.py +0 -0
  78. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  79. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  80. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/slash_picker.py +0 -0
  81. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/screens/term_dashboard_helpers.py +0 -0
  82. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/slash_commands.py +0 -0
  83. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/status_words.py +0 -0
  84. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/term_app.py +0 -0
  85. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/ui_settings.py +0 -0
  86. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/widgets.py +0 -0
  87. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/src/kiwi_tui/worktrees.py +0 -0
  88. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/test_hello.py +0 -0
  89. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/__init__.py +0 -0
  90. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/conftest.py +0 -0
  91. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_account_server.py +0 -0
  92. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_autocode_actions.py +0 -0
  93. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_batch_fs_tool.py +0 -0
  94. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_checkpoints.py +0 -0
  95. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_cli_help.py +0 -0
  96. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_copy_path_fs_tool.py +0 -0
  97. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_fs_edit_fidelity.py +0 -0
  98. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_imports.py +0 -0
  99. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_login_social.py +0 -0
  100. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_oauth.py +0 -0
  101. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_read_file_streaming.py +0 -0
  102. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_reexec_kiwi.py +0 -0
  103. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_role_mode_injection.py +0 -0
  104. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_run_steering.py +0 -0
  105. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_runtime_cleanup_per_account.py +0 -0
  106. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_runtime_connection.py +0 -0
  107. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_runtime_fixes.py +0 -0
  108. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_runtime_imports_on_windows.py +0 -0
  109. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_runtime_log_trimming.py +0 -0
  110. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_runtime_social_login.py +0 -0
  111. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_search_in_files_fs_tool.py +0 -0
  112. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_slash_commands.py +0 -0
  113. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_stats_command.py +0 -0
  114. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_term_dashboard_ui.py +0 -0
  115. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_terminal_mode.py +0 -0
  116. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_tokens.py +0 -0
  117. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_tui_interactive_runtime.py +0 -0
  118. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_tui_palette.py +0 -0
  119. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/tests/test_unified_diff.py +0 -0
  120. {kiwi_code-0.0.501 → kiwi_code-0.0.502}/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.502
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.502"
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,12 @@ 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.models.human_answers_request import HumanAnswersRequest
16
20
  from pykiwiai.models.body_return_user_and_session_v1_auth_post import (
17
21
  BodyReturnUserAndSessionV1AuthPost,
18
22
  )
@@ -21,6 +25,7 @@ from pykiwiai.models.body_async_run_action_v1_actions_id_async_run_post import (
21
25
  )
22
26
  from pykiwiai.models import DataBlock, Metadata
23
27
  from .models import AuthTokens, LoginCredentials
28
+ from .human_questions import WAITING_STATUS, human_event_type, parse_questions
24
29
 
25
30
 
26
31
  class AutobotsClientWrapper:
@@ -545,6 +550,11 @@ class AutobotsClientWrapper:
545
550
  except Exception:
546
551
  pass
547
552
 
553
+ questions = getattr(result, "human_questions", None)
554
+ result_dict["human_questions"] = parse_questions(
555
+ [q.to_dict() for q in questions] if isinstance(questions, list) else None
556
+ )
557
+
548
558
  result_dict["id"] = run_id
549
559
 
550
560
  # Prompts waiting behind the current turn: with any left, a finished turn
@@ -604,6 +614,10 @@ class AutobotsClientWrapper:
604
614
  elif status == "cancelled":
605
615
  return False, result, "Action cancelled"
606
616
 
617
+ # The turn ended asking the user something: done until they answer.
618
+ elif status == WAITING_STATUS:
619
+ return True, result, "Action waiting for an answer"
620
+
607
621
  # Still processing (a run being cancelled is not over until it says so)
608
622
  elif status in ["processing", "running", "pending", "request_cancel"]:
609
623
  logger.debug(f"Action still {status}, attempt {attempt + 1}/{max_attempts}")
@@ -684,7 +698,12 @@ class AutobotsClientWrapper:
684
698
  try:
685
699
  data = json.loads(data_str)
686
700
  on_message(data)
687
- if stop_on_terminal_status and isinstance(data, dict):
701
+ # A question event's `status` is the questions', not the run's.
702
+ if (
703
+ stop_on_terminal_status
704
+ and isinstance(data, dict)
705
+ and human_event_type(data) is None
706
+ ):
688
707
  status = str(data.get("status", "") or "").lower()
689
708
  if status in ["completed", "success", "finished", "failed", "error", "cancelled"]:
690
709
  return True
@@ -751,6 +770,47 @@ class AutobotsClientWrapper:
751
770
  logger.error(f"SSE error: {e}")
752
771
  raise
753
772
 
773
+ def answer_human_questions(
774
+ self, run_id: str, answers: list[dict]
775
+ ) -> tuple[int, Optional[dict], str]:
776
+ """Answer questions the run's model asked with `ask_human`.
777
+
778
+ `POST /v1/actions/{run_id}/answer`. Every open question must be in `answers`, each
779
+ answered (`selected` and/or `text`) or skipped (`skip: true`); recorded all or nothing.
780
+
781
+ Returns:
782
+ Tuple of (http_status, body, message). On 200 the body has `question_ids` and
783
+ `delivered_as`: "turn" when the run had finished and reopened as processing,
784
+ "instruct" when it was still running. 0 means the request did not complete.
785
+ """
786
+ try:
787
+ if not isinstance(self.client, AuthenticatedClient):
788
+ return 401, None, "Authentication required"
789
+ response = answer_human_questions_v1_actions_action_result_id_answer_post.sync_detailed(
790
+ action_result_id=run_id,
791
+ client=self.client,
792
+ body=HumanAnswersRequest.from_dict({"answers": answers}),
793
+ )
794
+ code = int(response.status_code)
795
+ if code == 200 and response.parsed is not None and hasattr(response.parsed, "delivered_as"):
796
+ body = response.parsed.to_dict()
797
+ logger.info(f"Answered {len(answers)} question(s) on run {run_id}: {body}")
798
+ return 200, body, "Answer sent"
799
+ # Only 200 and 422 are typed; the 400/403/404/409 `detail` is read from the body.
800
+ try:
801
+ body = json.loads(response.content or b"null")
802
+ except ValueError:
803
+ body = None
804
+ detail = body.get("detail") if isinstance(body, dict) else None
805
+ if not isinstance(detail, str):
806
+ detail = json.dumps(detail) if detail else (response.content or b"").decode("utf-8", "replace")
807
+ message = f"HTTP {code}" + (f": {detail}" if detail else "")
808
+ logger.warning(f"Answering questions on run {run_id} failed: {message}")
809
+ return code, body if isinstance(body, dict) else None, message
810
+ except Exception as e:
811
+ logger.error(f"Error answering questions on run {run_id}: {e}")
812
+ return 0, None, f"Error sending answer: {e}"
813
+
754
814
  def close(self) -> None:
755
815
  """Close the client connection."""
756
816
  try:
@@ -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