codex-autorunner 1.0.0__py3-none-any.whl → 1.1.0__py3-none-any.whl

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 (170) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/constants.py +3 -0
  4. codex_autorunner/agents/opencode/harness.py +6 -1
  5. codex_autorunner/agents/opencode/runtime.py +59 -18
  6. codex_autorunner/agents/registry.py +22 -3
  7. codex_autorunner/bootstrap.py +7 -3
  8. codex_autorunner/cli.py +5 -1174
  9. codex_autorunner/codex_cli.py +20 -84
  10. codex_autorunner/core/__init__.py +4 -0
  11. codex_autorunner/core/about_car.py +6 -1
  12. codex_autorunner/core/app_server_ids.py +59 -0
  13. codex_autorunner/core/app_server_threads.py +11 -2
  14. codex_autorunner/core/app_server_utils.py +165 -0
  15. codex_autorunner/core/archive.py +349 -0
  16. codex_autorunner/core/codex_runner.py +6 -2
  17. codex_autorunner/core/config.py +197 -3
  18. codex_autorunner/core/drafts.py +58 -4
  19. codex_autorunner/core/engine.py +1329 -680
  20. codex_autorunner/core/exceptions.py +4 -0
  21. codex_autorunner/core/flows/controller.py +25 -1
  22. codex_autorunner/core/flows/models.py +13 -0
  23. codex_autorunner/core/flows/reasons.py +52 -0
  24. codex_autorunner/core/flows/reconciler.py +131 -0
  25. codex_autorunner/core/flows/runtime.py +35 -4
  26. codex_autorunner/core/flows/store.py +83 -0
  27. codex_autorunner/core/flows/transition.py +5 -0
  28. codex_autorunner/core/flows/ux_helpers.py +257 -0
  29. codex_autorunner/core/git_utils.py +62 -0
  30. codex_autorunner/core/hub.py +121 -7
  31. codex_autorunner/core/notifications.py +14 -2
  32. codex_autorunner/core/ports/__init__.py +28 -0
  33. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +11 -3
  34. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  35. codex_autorunner/{integrations/agents → core/ports}/run_event.py +22 -2
  36. codex_autorunner/core/state_roots.py +57 -0
  37. codex_autorunner/core/supervisor_protocol.py +15 -0
  38. codex_autorunner/core/text_delta_coalescer.py +54 -0
  39. codex_autorunner/core/ticket_linter_cli.py +201 -0
  40. codex_autorunner/core/ticket_manager_cli.py +432 -0
  41. codex_autorunner/core/update.py +4 -5
  42. codex_autorunner/core/update_paths.py +28 -0
  43. codex_autorunner/core/usage.py +164 -12
  44. codex_autorunner/core/utils.py +91 -9
  45. codex_autorunner/flows/review/__init__.py +17 -0
  46. codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
  47. codex_autorunner/flows/ticket_flow/definition.py +9 -2
  48. codex_autorunner/integrations/agents/__init__.py +9 -19
  49. codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
  50. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  51. codex_autorunner/integrations/agents/codex_backend.py +158 -17
  52. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  53. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  54. codex_autorunner/integrations/agents/runner.py +91 -0
  55. codex_autorunner/integrations/agents/wiring.py +271 -0
  56. codex_autorunner/integrations/app_server/client.py +7 -60
  57. codex_autorunner/integrations/app_server/env.py +2 -107
  58. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  59. codex_autorunner/integrations/telegram/adapter.py +65 -0
  60. codex_autorunner/integrations/telegram/config.py +46 -0
  61. codex_autorunner/integrations/telegram/constants.py +1 -1
  62. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  63. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1203 -66
  64. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +4 -3
  65. codex_autorunner/integrations/telegram/handlers/commands_spec.py +8 -2
  66. codex_autorunner/integrations/telegram/handlers/messages.py +1 -0
  67. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  68. codex_autorunner/integrations/telegram/helpers.py +24 -1
  69. codex_autorunner/integrations/telegram/service.py +15 -10
  70. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +329 -40
  71. codex_autorunner/integrations/telegram/transport.py +3 -1
  72. codex_autorunner/routes/__init__.py +37 -76
  73. codex_autorunner/routes/agents.py +2 -137
  74. codex_autorunner/routes/analytics.py +2 -238
  75. codex_autorunner/routes/app_server.py +2 -131
  76. codex_autorunner/routes/base.py +2 -596
  77. codex_autorunner/routes/file_chat.py +4 -833
  78. codex_autorunner/routes/flows.py +4 -977
  79. codex_autorunner/routes/messages.py +4 -456
  80. codex_autorunner/routes/repos.py +2 -196
  81. codex_autorunner/routes/review.py +2 -147
  82. codex_autorunner/routes/sessions.py +2 -175
  83. codex_autorunner/routes/settings.py +2 -168
  84. codex_autorunner/routes/shared.py +2 -275
  85. codex_autorunner/routes/system.py +4 -193
  86. codex_autorunner/routes/usage.py +2 -86
  87. codex_autorunner/routes/voice.py +2 -119
  88. codex_autorunner/routes/workspace.py +2 -270
  89. codex_autorunner/server.py +2 -2
  90. codex_autorunner/static/agentControls.js +40 -11
  91. codex_autorunner/static/app.js +11 -3
  92. codex_autorunner/static/archive.js +826 -0
  93. codex_autorunner/static/archiveApi.js +37 -0
  94. codex_autorunner/static/autoRefresh.js +7 -7
  95. codex_autorunner/static/dashboard.js +224 -171
  96. codex_autorunner/static/hub.js +112 -94
  97. codex_autorunner/static/index.html +80 -33
  98. codex_autorunner/static/messages.js +486 -83
  99. codex_autorunner/static/preserve.js +17 -0
  100. codex_autorunner/static/settings.js +125 -6
  101. codex_autorunner/static/smartRefresh.js +52 -0
  102. codex_autorunner/static/styles.css +1373 -101
  103. codex_autorunner/static/tabs.js +152 -11
  104. codex_autorunner/static/terminal.js +18 -0
  105. codex_autorunner/static/ticketEditor.js +99 -5
  106. codex_autorunner/static/tickets.js +760 -87
  107. codex_autorunner/static/utils.js +11 -0
  108. codex_autorunner/static/workspace.js +133 -40
  109. codex_autorunner/static/workspaceFileBrowser.js +9 -9
  110. codex_autorunner/surfaces/__init__.py +5 -0
  111. codex_autorunner/surfaces/cli/__init__.py +6 -0
  112. codex_autorunner/surfaces/cli/cli.py +1224 -0
  113. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  114. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  115. codex_autorunner/surfaces/web/__init__.py +1 -0
  116. codex_autorunner/surfaces/web/app.py +2019 -0
  117. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  118. codex_autorunner/surfaces/web/middleware.py +587 -0
  119. codex_autorunner/surfaces/web/pty_session.py +370 -0
  120. codex_autorunner/surfaces/web/review.py +6 -0
  121. codex_autorunner/surfaces/web/routes/__init__.py +78 -0
  122. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  123. codex_autorunner/surfaces/web/routes/analytics.py +277 -0
  124. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  125. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  126. codex_autorunner/surfaces/web/routes/base.py +615 -0
  127. codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
  128. codex_autorunner/surfaces/web/routes/flows.py +1164 -0
  129. codex_autorunner/surfaces/web/routes/messages.py +459 -0
  130. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  131. codex_autorunner/surfaces/web/routes/review.py +148 -0
  132. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  133. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  134. codex_autorunner/surfaces/web/routes/shared.py +280 -0
  135. codex_autorunner/surfaces/web/routes/system.py +196 -0
  136. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  137. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  138. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  139. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  140. codex_autorunner/surfaces/web/schemas.py +417 -0
  141. codex_autorunner/surfaces/web/static_assets.py +490 -0
  142. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  143. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  144. codex_autorunner/tickets/__init__.py +8 -1
  145. codex_autorunner/tickets/agent_pool.py +26 -4
  146. codex_autorunner/tickets/files.py +6 -2
  147. codex_autorunner/tickets/models.py +3 -1
  148. codex_autorunner/tickets/outbox.py +12 -0
  149. codex_autorunner/tickets/runner.py +63 -5
  150. codex_autorunner/web/__init__.py +5 -1
  151. codex_autorunner/web/app.py +2 -1949
  152. codex_autorunner/web/hub_jobs.py +2 -191
  153. codex_autorunner/web/middleware.py +2 -586
  154. codex_autorunner/web/pty_session.py +2 -369
  155. codex_autorunner/web/runner_manager.py +2 -24
  156. codex_autorunner/web/schemas.py +2 -376
  157. codex_autorunner/web/static_assets.py +4 -441
  158. codex_autorunner/web/static_refresh.py +2 -85
  159. codex_autorunner/web/terminal_sessions.py +2 -77
  160. codex_autorunner/workspace/paths.py +49 -33
  161. codex_autorunner-1.1.0.dist-info/METADATA +154 -0
  162. codex_autorunner-1.1.0.dist-info/RECORD +308 -0
  163. codex_autorunner/core/static_assets.py +0 -55
  164. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  165. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  166. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  167. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +0 -0
  168. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
  169. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
  170. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
@@ -1,84 +1,20 @@
1
- import subprocess
2
- from functools import lru_cache
3
- from typing import Iterable, Optional
4
-
5
- SUBCOMMAND_HINTS = ("exec", "resume")
6
-
7
-
8
- def extract_flag_value(args: Iterable[str], flag: str) -> Optional[str]:
9
- if not args:
10
- return None
11
- for arg in args:
12
- if not isinstance(arg, str):
13
- continue
14
- if arg.startswith(f"{flag}="):
15
- return arg.split("=", 1)[1] or None
16
- args_list = [str(a) for a in args]
17
- for idx, arg in enumerate(args_list):
18
- if arg == flag and idx + 1 < len(args_list):
19
- return args_list[idx + 1]
20
- return None
21
-
22
-
23
- def inject_flag(
24
- args: Iterable[str],
25
- flag: str,
26
- value: Optional[str],
27
- *,
28
- subcommands: Iterable[str] = SUBCOMMAND_HINTS,
29
- ) -> list[str]:
30
- if not value:
31
- return [str(a) for a in args]
32
- args_list = [str(a) for a in args]
33
- if extract_flag_value(args_list, flag):
34
- return args_list
35
- insert_at = None
36
- for cmd in subcommands:
37
- try:
38
- insert_at = args_list.index(cmd)
39
- break
40
- except ValueError:
41
- continue
42
- if insert_at is None:
43
- # `args` is sometimes a full argv that includes the binary at index 0,
44
- # e.g. ["codex", "--yolo", ...]. In that case, never prepend flags before
45
- # the binary or we'll turn argv[0] into e.g. "--model" and crash at spawn.
46
- if args_list and not args_list[0].startswith("-"):
47
- return [args_list[0], flag, value] + args_list[1:]
48
- return [flag, value] + args_list
49
- return args_list[:insert_at] + [flag, value] + args_list[insert_at:]
50
-
51
-
52
- def apply_codex_options(
53
- args: Iterable[str],
54
- *,
55
- model: Optional[str] = None,
56
- reasoning: Optional[str] = None,
57
- supports_reasoning: Optional[bool] = None,
58
- ) -> list[str]:
59
- with_model = inject_flag(args, "--model", model)
60
- if reasoning and supports_reasoning is False:
61
- return with_model
62
- return inject_flag(with_model, "--reasoning", reasoning)
63
-
64
-
65
- def _read_help_text(binary: str) -> str:
66
- try:
67
- result = subprocess.run(
68
- [binary, "--help"],
69
- capture_output=True,
70
- text=True,
71
- check=False,
72
- )
73
- except FileNotFoundError:
74
- return ""
75
- return "\n".join(filter(None, [result.stdout, result.stderr]))
76
-
77
-
78
- @lru_cache(maxsize=8)
79
- def supports_flag(binary: str, flag: str) -> bool:
80
- return flag in _read_help_text(binary)
81
-
82
-
83
- def supports_reasoning(binary: str) -> bool:
84
- return supports_flag(binary, "--reasoning")
1
+ """Backward-compatible Codex CLI helpers.
2
+
3
+ Delegates to core.utils to avoid duplicated logic.
4
+ """
5
+
6
+ from .core.utils import ( # noqa: F401
7
+ apply_codex_options,
8
+ extract_flag_value,
9
+ inject_flag,
10
+ supports_flag,
11
+ supports_reasoning,
12
+ )
13
+
14
+ __all__ = [
15
+ "apply_codex_options",
16
+ "extract_flag_value",
17
+ "inject_flag",
18
+ "supports_flag",
19
+ "supports_reasoning",
20
+ ]
@@ -1 +1,5 @@
1
1
  """Core runtime primitives."""
2
+
3
+ from .archive import ArchiveResult, archive_worktree_snapshot
4
+
5
+ __all__ = ["ArchiveResult", "archive_worktree_snapshot"]
@@ -76,7 +76,8 @@ def build_about_car_markdown(
76
76
  "You are running inside **Codex Autorunner (CAR)**.\n\n"
77
77
  "CAR uses a ticket-first workflow.\n\n"
78
78
  "## Required for operation\n"
79
- "- Tickets live under `.codex-autorunner/tickets/`.\n\n"
79
+ "- Tickets live under `.codex-autorunner/tickets/`.\n"
80
+ "- Lint ticket frontmatter after edits (runs against all tickets): `python3 .codex-autorunner/bin/lint_tickets.py`.\n\n"
80
81
  "## Optional workspace docs\n"
81
82
  "- **Active context**: "
82
83
  f"`{active_context_disp}`\n"
@@ -91,6 +92,10 @@ def build_about_car_markdown(
91
92
  "- **Dispatch**: An update or message from the agent.\n"
92
93
  "- **Handoff**: Passing control from agent to user (or vice versa).\n"
93
94
  "- **Inbox**: Where the agent receives files/messages.\n\n"
95
+ "## Ticket helpers\n"
96
+ "- Use `.codex-autorunner/bin/ticket_tool.py` to list/create/insert/move tickets; it is portable and venv-free.\n"
97
+ '- Common workflows: insert gap before N (`python3 .codex-autorunner/bin/ticket_tool.py insert --before N`); move a block (`... move --start A --end B --to T`); create with auto-quoted frontmatter (`... create --title "Fix #123" --agent codex`).\n'
98
+ "- After any ticket edits, lint all tickets: `python3 .codex-autorunner/bin/lint_tickets.py`.\n\n"
94
99
  "## How CAR works (short)\n"
95
100
  "- The web UI provides ticket editing + unified file chat.\n"
96
101
  "- `car serve` starts the hub web UI. The **Terminal** tab launches the configured `codex` binary in a PTY.\n"
@@ -0,0 +1,59 @@
1
+ from typing import Any, Optional
2
+
3
+
4
+ def extract_turn_id(payload: Any) -> Optional[str]:
5
+ if not isinstance(payload, dict):
6
+ return None
7
+ for key in ("turnId", "turn_id", "id"):
8
+ value = payload.get(key)
9
+ if isinstance(value, str):
10
+ return value
11
+ turn = payload.get("turn")
12
+ if isinstance(turn, dict):
13
+ for key in ("id", "turnId", "turn_id"):
14
+ value = turn.get(key)
15
+ if isinstance(value, str):
16
+ return value
17
+ return None
18
+
19
+
20
+ def _extract_thread_id_from_container(payload: Any) -> Optional[str]:
21
+ if not isinstance(payload, dict):
22
+ return None
23
+ for key in ("threadId", "thread_id"):
24
+ value = payload.get(key)
25
+ if isinstance(value, str):
26
+ return value
27
+ thread = payload.get("thread")
28
+ if isinstance(thread, dict):
29
+ for key in ("id", "threadId", "thread_id"):
30
+ value = thread.get(key)
31
+ if isinstance(value, str):
32
+ return value
33
+ return None
34
+
35
+
36
+ def extract_thread_id_for_turn(payload: Any) -> Optional[str]:
37
+ if not isinstance(payload, dict):
38
+ return None
39
+ for candidate in (payload, payload.get("turn"), payload.get("item")):
40
+ thread_id = _extract_thread_id_from_container(candidate)
41
+ if thread_id:
42
+ return thread_id
43
+ return None
44
+
45
+
46
+ def extract_thread_id(payload: Any) -> Optional[str]:
47
+ if not isinstance(payload, dict):
48
+ return None
49
+ for key in ("threadId", "thread_id", "id"):
50
+ value = payload.get(key)
51
+ if isinstance(value, str):
52
+ return value
53
+ thread = payload.get("thread")
54
+ if isinstance(thread, dict):
55
+ for key in ("id", "threadId", "thread_id"):
56
+ value = thread.get(key)
57
+ if isinstance(value, str):
58
+ return value
59
+ return None
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import logging
4
5
  from datetime import datetime, timezone
5
6
  from pathlib import Path
6
7
  from typing import Optional
@@ -17,6 +18,8 @@ FILE_CHAT_OPENCODE_KEY = "file_chat.opencode"
17
18
  FILE_CHAT_PREFIX = "file_chat."
18
19
  FILE_CHAT_OPENCODE_PREFIX = "file_chat.opencode."
19
20
 
21
+ LOGGER = logging.getLogger("codex_autorunner.app_server")
22
+
20
23
  # Static keys that can be reset/managed via the UI.
21
24
  FEATURE_KEYS = {
22
25
  FILE_CHAT_KEY,
@@ -177,8 +180,14 @@ class AppServerThreadRegistry:
177
180
  try:
178
181
  atomic_write(self._notice_path(), json.dumps(notice, indent=2) + "\n")
179
182
  except Exception:
180
- pass
183
+ LOGGER.warning(
184
+ "Failed to write app server thread corruption notice.",
185
+ exc_info=True,
186
+ )
181
187
  try:
182
188
  self._save_unlocked({})
183
189
  except Exception:
184
- pass
190
+ LOGGER.warning(
191
+ "Failed to reset app server thread registry after corruption.",
192
+ exc_info=True,
193
+ )
@@ -0,0 +1,165 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Any, Optional, Sequence
4
+
5
+ from .logging_utils import log_event
6
+ from .utils import resolve_executable, subprocess_env
7
+
8
+
9
+ def app_server_env(
10
+ command: Sequence[str],
11
+ cwd: Path,
12
+ *,
13
+ base_env: Optional[dict[str, str]] = None,
14
+ ) -> dict[str, str]:
15
+ extra_paths: list[str] = []
16
+ if command:
17
+ binary = command[0]
18
+ resolved = resolve_executable(binary, env=base_env)
19
+ candidate: Optional[Path] = Path(resolved) if resolved else None
20
+ if candidate is None:
21
+ candidate = Path(binary).expanduser()
22
+ if not candidate.is_absolute():
23
+ candidate = (cwd / candidate).resolve()
24
+ if candidate.exists():
25
+ extra_paths.append(str(candidate.parent))
26
+ return subprocess_env(extra_paths=extra_paths, base_env=base_env)
27
+
28
+
29
+ def seed_codex_home(
30
+ codex_home: Path,
31
+ *,
32
+ logger: Any = None,
33
+ event_prefix: str = "app_server",
34
+ ) -> None:
35
+ logger = logger or __import__("logging").getLogger(__name__)
36
+ auth_path = codex_home / "auth.json"
37
+ source_root = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser()
38
+ if source_root.resolve() == codex_home.resolve():
39
+ return
40
+ source_auth = source_root / "auth.json"
41
+ if auth_path.exists():
42
+ if auth_path.is_symlink() and auth_path.resolve() == source_auth.resolve():
43
+ return
44
+ log_event(
45
+ logger,
46
+ __import__("logging").INFO,
47
+ f"{event_prefix}.codex_home.seed.skipped",
48
+ reason="auth_exists",
49
+ source=str(source_root),
50
+ target=str(codex_home),
51
+ )
52
+ return
53
+ if not source_root.exists():
54
+ log_event(
55
+ logger,
56
+ __import__("logging").WARNING,
57
+ f"{event_prefix}.codex_home.seed.skipped",
58
+ reason="source_missing",
59
+ source=str(source_root),
60
+ target=str(codex_home),
61
+ )
62
+ return
63
+ if not source_auth.exists():
64
+ log_event(
65
+ logger,
66
+ __import__("logging").WARNING,
67
+ f"{event_prefix}.codex_home.seed.skipped",
68
+ reason="auth_missing",
69
+ source=str(source_root),
70
+ target=str(codex_home),
71
+ )
72
+ return
73
+ try:
74
+ auth_path.symlink_to(source_auth)
75
+ log_event(
76
+ logger,
77
+ __import__("logging").INFO,
78
+ f"{event_prefix}.codex_home.seeded",
79
+ source=str(source_root),
80
+ target=str(codex_home),
81
+ )
82
+ except OSError as exc:
83
+ log_event(
84
+ logger,
85
+ __import__("logging").WARNING,
86
+ f"{event_prefix}.codex_home.seed.failed",
87
+ exc=exc,
88
+ source=str(source_root),
89
+ target=str(codex_home),
90
+ )
91
+
92
+
93
+ def build_app_server_env(
94
+ command: Sequence[str],
95
+ workspace_root: Path,
96
+ state_dir: Path,
97
+ *,
98
+ logger: Any = None,
99
+ event_prefix: str = "app_server",
100
+ base_env: Optional[dict[str, str]] = None,
101
+ ) -> dict[str, str]:
102
+ env = app_server_env(command, workspace_root, base_env=base_env)
103
+ codex_home = state_dir / "codex_home"
104
+ codex_home.mkdir(parents=True, exist_ok=True)
105
+ seed_codex_home(codex_home, logger=logger, event_prefix=event_prefix)
106
+ env["CODEX_HOME"] = str(codex_home)
107
+ return env
108
+
109
+
110
+ def _extract_turn_id(payload: Any) -> Optional[str]:
111
+ if not isinstance(payload, dict):
112
+ return None
113
+ for key in ("turnId", "turn_id", "id"):
114
+ value = payload.get(key)
115
+ if isinstance(value, str):
116
+ return value
117
+ turn = payload.get("turn")
118
+ if isinstance(turn, dict):
119
+ for key in ("id", "turnId", "turn_id"):
120
+ value = turn.get(key)
121
+ if isinstance(value, str):
122
+ return value
123
+ return None
124
+
125
+
126
+ def _extract_thread_id_from_container(payload: Any) -> Optional[str]:
127
+ if not isinstance(payload, dict):
128
+ return None
129
+ for key in ("threadId", "thread_id"):
130
+ value = payload.get(key)
131
+ if isinstance(value, str):
132
+ return value
133
+ thread = payload.get("thread")
134
+ if isinstance(thread, dict):
135
+ for key in ("id", "threadId", "thread_id"):
136
+ value = thread.get(key)
137
+ if isinstance(value, str):
138
+ return value
139
+ return None
140
+
141
+
142
+ def _extract_thread_id_for_turn(payload: Any) -> Optional[str]:
143
+ if not isinstance(payload, dict):
144
+ return None
145
+ for candidate in (payload, payload.get("turn"), payload.get("item")):
146
+ thread_id = _extract_thread_id_from_container(candidate)
147
+ if thread_id:
148
+ return thread_id
149
+ return None
150
+
151
+
152
+ def _extract_thread_id(payload: Any) -> Optional[str]:
153
+ if not isinstance(payload, dict):
154
+ return None
155
+ for key in ("threadId", "thread_id", "id"):
156
+ value = payload.get(key)
157
+ if isinstance(value, str):
158
+ return value
159
+ thread = payload.get("thread")
160
+ if isinstance(thread, dict):
161
+ for key in ("id", "threadId", "thread_id"):
162
+ value = thread.get(key)
163
+ if isinstance(value, str):
164
+ return value
165
+ return None