codex-autorunner 0.1.2__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 (276) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/__main__.py +4 -0
  3. codex_autorunner/agents/codex/harness.py +1 -1
  4. codex_autorunner/agents/opencode/client.py +68 -35
  5. codex_autorunner/agents/opencode/constants.py +3 -0
  6. codex_autorunner/agents/opencode/harness.py +6 -1
  7. codex_autorunner/agents/opencode/logging.py +21 -5
  8. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  9. codex_autorunner/agents/opencode/runtime.py +176 -47
  10. codex_autorunner/agents/opencode/supervisor.py +36 -48
  11. codex_autorunner/agents/registry.py +155 -8
  12. codex_autorunner/api.py +25 -0
  13. codex_autorunner/bootstrap.py +22 -37
  14. codex_autorunner/cli.py +5 -1156
  15. codex_autorunner/codex_cli.py +20 -84
  16. codex_autorunner/core/__init__.py +4 -0
  17. codex_autorunner/core/about_car.py +49 -32
  18. codex_autorunner/core/adapter_utils.py +21 -0
  19. codex_autorunner/core/app_server_ids.py +59 -0
  20. codex_autorunner/core/app_server_logging.py +7 -3
  21. codex_autorunner/core/app_server_prompts.py +27 -260
  22. codex_autorunner/core/app_server_threads.py +26 -28
  23. codex_autorunner/core/app_server_utils.py +165 -0
  24. codex_autorunner/core/archive.py +349 -0
  25. codex_autorunner/core/codex_runner.py +12 -2
  26. codex_autorunner/core/config.py +587 -103
  27. codex_autorunner/core/docs.py +10 -2
  28. codex_autorunner/core/drafts.py +136 -0
  29. codex_autorunner/core/engine.py +1531 -866
  30. codex_autorunner/core/exceptions.py +4 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +202 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +88 -0
  35. codex_autorunner/core/flows/reasons.py +52 -0
  36. codex_autorunner/core/flows/reconciler.py +131 -0
  37. codex_autorunner/core/flows/runtime.py +382 -0
  38. codex_autorunner/core/flows/store.py +568 -0
  39. codex_autorunner/core/flows/transition.py +138 -0
  40. codex_autorunner/core/flows/ux_helpers.py +257 -0
  41. codex_autorunner/core/flows/worker_process.py +242 -0
  42. codex_autorunner/core/git_utils.py +62 -0
  43. codex_autorunner/core/hub.py +136 -16
  44. codex_autorunner/core/locks.py +4 -0
  45. codex_autorunner/core/notifications.py +14 -2
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/core/ports/agent_backend.py +150 -0
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/core/ports/run_event.py +91 -0
  50. codex_autorunner/core/prompt.py +15 -7
  51. codex_autorunner/core/redaction.py +29 -0
  52. codex_autorunner/core/review_context.py +5 -8
  53. codex_autorunner/core/run_index.py +6 -0
  54. codex_autorunner/core/runner_process.py +5 -2
  55. codex_autorunner/core/state.py +0 -88
  56. codex_autorunner/core/state_roots.py +57 -0
  57. codex_autorunner/core/supervisor_protocol.py +15 -0
  58. codex_autorunner/core/supervisor_utils.py +67 -0
  59. codex_autorunner/core/text_delta_coalescer.py +54 -0
  60. codex_autorunner/core/ticket_linter_cli.py +201 -0
  61. codex_autorunner/core/ticket_manager_cli.py +432 -0
  62. codex_autorunner/core/update.py +24 -16
  63. codex_autorunner/core/update_paths.py +28 -0
  64. codex_autorunner/core/update_runner.py +2 -0
  65. codex_autorunner/core/usage.py +164 -12
  66. codex_autorunner/core/utils.py +120 -11
  67. codex_autorunner/discovery.py +2 -4
  68. codex_autorunner/flows/review/__init__.py +17 -0
  69. codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
  70. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  71. codex_autorunner/flows/ticket_flow/definition.py +98 -0
  72. codex_autorunner/integrations/agents/__init__.py +17 -0
  73. codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
  74. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  75. codex_autorunner/integrations/agents/codex_backend.py +448 -0
  76. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  77. codex_autorunner/integrations/agents/opencode_backend.py +598 -0
  78. codex_autorunner/integrations/agents/runner.py +91 -0
  79. codex_autorunner/integrations/agents/wiring.py +271 -0
  80. codex_autorunner/integrations/app_server/client.py +583 -152
  81. codex_autorunner/integrations/app_server/env.py +2 -107
  82. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  83. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  84. codex_autorunner/integrations/telegram/adapter.py +204 -165
  85. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  86. codex_autorunner/integrations/telegram/config.py +221 -0
  87. codex_autorunner/integrations/telegram/constants.py +17 -2
  88. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  89. codex_autorunner/integrations/telegram/doctor.py +47 -0
  90. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -4
  91. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  92. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  93. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  94. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1364 -0
  95. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  96. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  97. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  98. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +137 -478
  99. codex_autorunner/integrations/telegram/handlers/commands_spec.py +17 -4
  100. codex_autorunner/integrations/telegram/handlers/messages.py +121 -9
  101. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  102. codex_autorunner/integrations/telegram/helpers.py +111 -16
  103. codex_autorunner/integrations/telegram/outbox.py +208 -37
  104. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  105. codex_autorunner/integrations/telegram/service.py +221 -42
  106. codex_autorunner/integrations/telegram/state.py +100 -2
  107. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +611 -0
  108. codex_autorunner/integrations/telegram/transport.py +39 -4
  109. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  110. codex_autorunner/manifest.py +2 -0
  111. codex_autorunner/plugin_api.py +22 -0
  112. codex_autorunner/routes/__init__.py +37 -67
  113. codex_autorunner/routes/agents.py +2 -137
  114. codex_autorunner/routes/analytics.py +3 -0
  115. codex_autorunner/routes/app_server.py +2 -131
  116. codex_autorunner/routes/base.py +2 -624
  117. codex_autorunner/routes/file_chat.py +7 -0
  118. codex_autorunner/routes/flows.py +7 -0
  119. codex_autorunner/routes/messages.py +7 -0
  120. codex_autorunner/routes/repos.py +2 -196
  121. codex_autorunner/routes/review.py +2 -147
  122. codex_autorunner/routes/sessions.py +2 -175
  123. codex_autorunner/routes/settings.py +2 -168
  124. codex_autorunner/routes/shared.py +2 -275
  125. codex_autorunner/routes/system.py +4 -188
  126. codex_autorunner/routes/usage.py +3 -0
  127. codex_autorunner/routes/voice.py +2 -119
  128. codex_autorunner/routes/workspace.py +3 -0
  129. codex_autorunner/server.py +3 -2
  130. codex_autorunner/static/agentControls.js +41 -11
  131. codex_autorunner/static/agentEvents.js +248 -0
  132. codex_autorunner/static/app.js +35 -24
  133. codex_autorunner/static/archive.js +826 -0
  134. codex_autorunner/static/archiveApi.js +37 -0
  135. codex_autorunner/static/autoRefresh.js +36 -8
  136. codex_autorunner/static/bootstrap.js +1 -0
  137. codex_autorunner/static/bus.js +1 -0
  138. codex_autorunner/static/cache.js +1 -0
  139. codex_autorunner/static/constants.js +20 -4
  140. codex_autorunner/static/dashboard.js +344 -325
  141. codex_autorunner/static/diffRenderer.js +37 -0
  142. codex_autorunner/static/docChatCore.js +324 -0
  143. codex_autorunner/static/docChatStorage.js +65 -0
  144. codex_autorunner/static/docChatVoice.js +65 -0
  145. codex_autorunner/static/docEditor.js +133 -0
  146. codex_autorunner/static/env.js +1 -0
  147. codex_autorunner/static/eventSummarizer.js +166 -0
  148. codex_autorunner/static/fileChat.js +182 -0
  149. codex_autorunner/static/health.js +155 -0
  150. codex_autorunner/static/hub.js +126 -185
  151. codex_autorunner/static/index.html +839 -863
  152. codex_autorunner/static/liveUpdates.js +1 -0
  153. codex_autorunner/static/loader.js +1 -0
  154. codex_autorunner/static/messages.js +873 -0
  155. codex_autorunner/static/mobileCompact.js +2 -1
  156. codex_autorunner/static/preserve.js +17 -0
  157. codex_autorunner/static/settings.js +149 -217
  158. codex_autorunner/static/smartRefresh.js +52 -0
  159. codex_autorunner/static/styles.css +8850 -3876
  160. codex_autorunner/static/tabs.js +175 -11
  161. codex_autorunner/static/terminal.js +32 -0
  162. codex_autorunner/static/terminalManager.js +34 -59
  163. codex_autorunner/static/ticketChatActions.js +333 -0
  164. codex_autorunner/static/ticketChatEvents.js +16 -0
  165. codex_autorunner/static/ticketChatStorage.js +16 -0
  166. codex_autorunner/static/ticketChatStream.js +264 -0
  167. codex_autorunner/static/ticketEditor.js +844 -0
  168. codex_autorunner/static/ticketVoice.js +9 -0
  169. codex_autorunner/static/tickets.js +1988 -0
  170. codex_autorunner/static/utils.js +43 -3
  171. codex_autorunner/static/voice.js +1 -0
  172. codex_autorunner/static/workspace.js +765 -0
  173. codex_autorunner/static/workspaceApi.js +53 -0
  174. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  175. codex_autorunner/surfaces/__init__.py +5 -0
  176. codex_autorunner/surfaces/cli/__init__.py +6 -0
  177. codex_autorunner/surfaces/cli/cli.py +1224 -0
  178. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  179. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  180. codex_autorunner/surfaces/web/__init__.py +1 -0
  181. codex_autorunner/surfaces/web/app.py +2019 -0
  182. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  183. codex_autorunner/surfaces/web/middleware.py +587 -0
  184. codex_autorunner/surfaces/web/pty_session.py +370 -0
  185. codex_autorunner/surfaces/web/review.py +6 -0
  186. codex_autorunner/surfaces/web/routes/__init__.py +78 -0
  187. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  188. codex_autorunner/surfaces/web/routes/analytics.py +277 -0
  189. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  190. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  191. codex_autorunner/surfaces/web/routes/base.py +615 -0
  192. codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
  193. codex_autorunner/surfaces/web/routes/flows.py +1164 -0
  194. codex_autorunner/surfaces/web/routes/messages.py +459 -0
  195. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  196. codex_autorunner/surfaces/web/routes/review.py +148 -0
  197. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  198. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  199. codex_autorunner/surfaces/web/routes/shared.py +280 -0
  200. codex_autorunner/surfaces/web/routes/system.py +196 -0
  201. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  202. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  203. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  204. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  205. codex_autorunner/surfaces/web/schemas.py +417 -0
  206. codex_autorunner/surfaces/web/static_assets.py +490 -0
  207. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  208. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  209. codex_autorunner/tickets/__init__.py +27 -0
  210. codex_autorunner/tickets/agent_pool.py +399 -0
  211. codex_autorunner/tickets/files.py +89 -0
  212. codex_autorunner/tickets/frontmatter.py +55 -0
  213. codex_autorunner/tickets/lint.py +102 -0
  214. codex_autorunner/tickets/models.py +97 -0
  215. codex_autorunner/tickets/outbox.py +244 -0
  216. codex_autorunner/tickets/replies.py +179 -0
  217. codex_autorunner/tickets/runner.py +881 -0
  218. codex_autorunner/tickets/spec_ingest.py +77 -0
  219. codex_autorunner/web/__init__.py +5 -1
  220. codex_autorunner/web/app.py +2 -1771
  221. codex_autorunner/web/hub_jobs.py +2 -191
  222. codex_autorunner/web/middleware.py +2 -587
  223. codex_autorunner/web/pty_session.py +2 -369
  224. codex_autorunner/web/runner_manager.py +2 -24
  225. codex_autorunner/web/schemas.py +2 -396
  226. codex_autorunner/web/static_assets.py +4 -484
  227. codex_autorunner/web/static_refresh.py +2 -85
  228. codex_autorunner/web/terminal_sessions.py +2 -77
  229. codex_autorunner/workspace/__init__.py +40 -0
  230. codex_autorunner/workspace/paths.py +335 -0
  231. codex_autorunner-1.1.0.dist-info/METADATA +154 -0
  232. codex_autorunner-1.1.0.dist-info/RECORD +308 -0
  233. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +1 -1
  234. codex_autorunner/agents/execution/policy.py +0 -292
  235. codex_autorunner/agents/factory.py +0 -52
  236. codex_autorunner/agents/orchestrator.py +0 -358
  237. codex_autorunner/core/doc_chat.py +0 -1446
  238. codex_autorunner/core/snapshot.py +0 -580
  239. codex_autorunner/integrations/github/chatops.py +0 -268
  240. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  241. codex_autorunner/routes/docs.py +0 -381
  242. codex_autorunner/routes/github.py +0 -327
  243. codex_autorunner/routes/runs.py +0 -250
  244. codex_autorunner/spec_ingest.py +0 -812
  245. codex_autorunner/static/docChatActions.js +0 -287
  246. codex_autorunner/static/docChatEvents.js +0 -300
  247. codex_autorunner/static/docChatRender.js +0 -205
  248. codex_autorunner/static/docChatStream.js +0 -361
  249. codex_autorunner/static/docs.js +0 -20
  250. codex_autorunner/static/docsClipboard.js +0 -69
  251. codex_autorunner/static/docsCrud.js +0 -257
  252. codex_autorunner/static/docsDocUpdates.js +0 -62
  253. codex_autorunner/static/docsDrafts.js +0 -16
  254. codex_autorunner/static/docsElements.js +0 -69
  255. codex_autorunner/static/docsInit.js +0 -285
  256. codex_autorunner/static/docsParse.js +0 -160
  257. codex_autorunner/static/docsSnapshot.js +0 -87
  258. codex_autorunner/static/docsSpecIngest.js +0 -263
  259. codex_autorunner/static/docsState.js +0 -127
  260. codex_autorunner/static/docsThreadRegistry.js +0 -44
  261. codex_autorunner/static/docsUi.js +0 -153
  262. codex_autorunner/static/docsVoice.js +0 -56
  263. codex_autorunner/static/github.js +0 -504
  264. codex_autorunner/static/logs.js +0 -678
  265. codex_autorunner/static/review.js +0 -157
  266. codex_autorunner/static/runs.js +0 -418
  267. codex_autorunner/static/snapshot.js +0 -124
  268. codex_autorunner/static/state.js +0 -94
  269. codex_autorunner/static/todoPreview.js +0 -27
  270. codex_autorunner/workspace.py +0 -16
  271. codex_autorunner-0.1.2.dist-info/METADATA +0 -249
  272. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  273. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  274. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
  275. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
  276. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
@@ -1,3 +1,14 @@
1
1
  """Codex autorunner package."""
2
2
 
3
- __all__ = ["cli", "core", "integrations", "routes", "server", "voice", "web"]
3
+ __all__ = [
4
+ "cli",
5
+ "core",
6
+ "integrations",
7
+ "routes",
8
+ "server",
9
+ "surfaces",
10
+ "surfaces.web.routes",
11
+ "surfaces.web",
12
+ "voice",
13
+ "web",
14
+ ]
@@ -0,0 +1,4 @@
1
+ from .cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from pathlib import Path
4
4
  from typing import Any, AsyncIterator, Optional
5
5
 
6
- from ...core.app_server_events import AppServerEventBuffer
6
+ from ...integrations.app_server.event_buffer import AppServerEventBuffer
7
7
  from ...integrations.app_server.supervisor import WorkspaceAppServerSupervisor
8
8
  from ..base import AgentHarness
9
9
  from ..types import AgentId, ConversationRef, ModelCatalog, ModelSpec, TurnRef
@@ -4,6 +4,7 @@ import asyncio
4
4
  import dataclasses
5
5
  import json
6
6
  import logging
7
+ import re
7
8
  from typing import Any, AsyncIterator, Iterable, Optional
8
9
 
9
10
  import httpx
@@ -48,7 +49,18 @@ def _normalize_sse_event(event: SSEEvent) -> SSEEvent:
48
49
  payload_obj = None
49
50
 
50
51
  if isinstance(payload_obj, dict) and isinstance(payload_obj.get("payload"), dict):
51
- payload_obj = payload_obj["payload"]
52
+ outer = payload_obj
53
+ inner = dict(outer.get("payload") or {})
54
+ if "type" not in inner and isinstance(outer.get("type"), str):
55
+ inner["type"] = outer["type"]
56
+ for key in ("sessionID", "sessionId", "session_id"):
57
+ if key in outer and key not in inner:
58
+ inner[key] = outer[key]
59
+ if "session" in outer and "session" not in inner:
60
+ inner["session"] = outer["session"]
61
+ if "properties" in outer and "properties" not in inner:
62
+ inner["properties"] = outer["properties"]
63
+ payload_obj = inner
52
64
 
53
65
  if isinstance(payload_obj, dict):
54
66
  payload_type = payload_obj.get("type")
@@ -505,49 +517,70 @@ class OpenCodeClient:
505
517
 
506
518
  async def fetch_openapi_spec(self) -> dict[str, Any]:
507
519
  """Fetch OpenAPI spec from /doc endpoint for capability negotiation."""
508
- async with self._client.stream("GET", "/doc") as response:
509
- response.raise_for_status()
510
- content = response.content
511
- try:
512
- spec = json.loads(content) if content else {}
513
- log_event(
514
- self._logger,
515
- logging.INFO,
516
- "opencode.openapi.fetched",
517
- paths=len(spec.get("paths", {})) if isinstance(spec, dict) else 0,
518
- has_components=(
519
- "components" in spec if isinstance(spec, dict) else False
520
- ),
521
- )
522
- return spec
523
- except Exception as exc:
524
- log_event(
525
- self._logger,
526
- logging.WARNING,
527
- "opencode.openapi.parse_failed",
528
- exc=exc,
529
- )
530
- raise OpenCodeProtocolError(
531
- f"Failed to parse OpenAPI spec: {exc}",
532
- status_code=response.status_code,
533
- content_type=(
534
- response.headers.get("content-type") if response else None
535
- ),
536
- ) from exc
520
+ response = await self._client.get("/doc")
521
+ response.raise_for_status()
522
+ content = response.content
523
+ try:
524
+ spec = json.loads(content) if content else {}
525
+ log_event(
526
+ self._logger,
527
+ logging.INFO,
528
+ "opencode.openapi.fetched",
529
+ paths=len(spec.get("paths", {})) if isinstance(spec, dict) else 0,
530
+ has_components=(
531
+ "components" in spec if isinstance(spec, dict) else False
532
+ ),
533
+ )
534
+ return spec
535
+ except Exception as exc:
536
+ log_event(
537
+ self._logger,
538
+ logging.WARNING,
539
+ "opencode.openapi.parse_failed",
540
+ exc=exc,
541
+ )
542
+ raise OpenCodeProtocolError(
543
+ f"Failed to parse OpenAPI spec: {exc}",
544
+ status_code=response.status_code,
545
+ content_type=(
546
+ response.headers.get("content-type") if response else None
547
+ ),
548
+ ) from exc
537
549
 
538
550
  def has_endpoint(
539
551
  self, openapi_spec: dict[str, Any], method: str, path: str
540
552
  ) -> bool:
541
- """Check if endpoint is available in OpenAPI spec."""
553
+ """Check if endpoint is available in OpenAPI spec.
554
+
555
+ The OpenAPI spec sometimes uses different template parameter names (e.g.,
556
+ `{sessionID}` vs `{session_id}`). We normalize templates before matching so
557
+ capability detection does not depend on placeholder spelling.
558
+ """
542
559
  if not isinstance(openapi_spec, dict):
543
560
  return False
544
561
  paths = openapi_spec.get("paths", {})
545
562
  if not isinstance(paths, dict):
546
563
  return False
547
- path_info = paths.get(path)
548
- if not isinstance(path_info, dict):
549
- return False
550
- return method in path_info
564
+
565
+ target = _normalize_template_path(path)
566
+ method = method.lower()
567
+
568
+ for candidate_path, info in paths.items():
569
+ if not isinstance(info, dict):
570
+ continue
571
+ if _normalize_template_path(candidate_path) != target:
572
+ continue
573
+ if method in info:
574
+ return True
575
+ return False
576
+
577
+
578
+ def _normalize_template_path(path: str) -> str:
579
+ """Collapse template placeholders to a canonical form.
580
+
581
+ Example: `/session/{sessionID}/prompt_async` -> `/session/{}/prompt_async`
582
+ """
583
+ return re.sub(r"{[^/]+}", "{}", path)
551
584
 
552
585
 
553
586
  __all__ = ["OpenCodeClient", "OpenCodeProtocolError", "OpenCodeApiProfile"]
@@ -0,0 +1,3 @@
1
+ DEFAULT_TICKET_MODEL = "zai-coding-plan/glm-4.7"
2
+
3
+ __all__ = ["DEFAULT_TICKET_MODEL"]
@@ -6,9 +6,10 @@ import logging
6
6
  from pathlib import Path
7
7
  from typing import Any, AsyncIterator, Optional
8
8
 
9
- from ...core.app_server_events import format_sse
9
+ from ...integrations.app_server.event_buffer import format_sse
10
10
  from ..base import AgentHarness
11
11
  from ..types import AgentId, ConversationRef, ModelCatalog, ModelSpec, TurnRef
12
+ from .constants import DEFAULT_TICKET_MODEL
12
13
  from .runtime import (
13
14
  build_turn_id,
14
15
  extract_session_id,
@@ -168,6 +169,8 @@ class OpenCodeHarness(AgentHarness):
168
169
  sandbox_policy: Optional[Any],
169
170
  ) -> TurnRef:
170
171
  client = await self._supervisor.get_client(workspace_root)
172
+ if model is None:
173
+ model = DEFAULT_TICKET_MODEL
171
174
  model_payload = split_model_id(model)
172
175
  await client.prompt_async(
173
176
  conversation_id,
@@ -192,6 +195,8 @@ class OpenCodeHarness(AgentHarness):
192
195
  sandbox_policy: Optional[Any],
193
196
  ) -> TurnRef:
194
197
  client = await self._supervisor.get_client(workspace_root)
198
+ if model is None:
199
+ model = DEFAULT_TICKET_MODEL
195
200
  arguments = prompt if prompt else ""
196
201
 
197
202
  async def _send_review() -> None:
@@ -66,11 +66,6 @@ class OpenCodeEventFormatter:
66
66
  for line in complete_lines:
67
67
  if line.strip():
68
68
  lines.append(f"**{line.strip()}**")
69
-
70
- remaining = coalescer.get_buffer()
71
- if remaining and remaining.strip():
72
- lines.append(f"**{remaining.strip()}**")
73
- coalescer.clear()
74
69
  return lines
75
70
 
76
71
  def _format_tool_part(self, part: dict[str, Any]) -> list[str]:
@@ -120,6 +115,27 @@ class OpenCodeEventFormatter:
120
115
  lines.append("exec")
121
116
  lines.append(f"tool: {tool_name}")
122
117
 
118
+ input_preview: Optional[str] = None
119
+ for key in ("input", "command", "cmd", "script"):
120
+ value = part.get(key)
121
+ if isinstance(value, str) and value.strip():
122
+ input_preview = value.strip()
123
+ break
124
+ if input_preview is None:
125
+ args = part.get("args") or part.get("arguments") or part.get("params")
126
+ if isinstance(args, dict):
127
+ for key in ("command", "cmd", "script", "input"):
128
+ value = args.get(key)
129
+ if isinstance(value, str) and value.strip():
130
+ input_preview = value.strip()
131
+ break
132
+ elif isinstance(args, str) and args.strip():
133
+ input_preview = args.strip()
134
+ if input_preview:
135
+ if len(input_preview) > 240:
136
+ input_preview = input_preview[:240] + "…"
137
+ lines.append(f"input: {input_preview}")
138
+
123
139
  return lines
124
140
 
125
141
  def _format_patch_part(self, part: dict[str, Any]) -> list[str]:
@@ -112,6 +112,7 @@ async def run_opencode_prompt(
112
112
  client,
113
113
  session_id=session_id,
114
114
  workspace_path=config.workspace_root,
115
+ model_payload=model_payload,
115
116
  permission_policy=permission_policy,
116
117
  should_stop=should_stop,
117
118
  ready_event=ready_event,
@@ -15,6 +15,7 @@ from typing import (
15
15
  Callable,
16
16
  MutableMapping,
17
17
  Optional,
18
+ cast,
18
19
  )
19
20
 
20
21
  import httpx
@@ -121,23 +122,37 @@ def extract_session_id(
121
122
  value = payload.get(key)
122
123
  if isinstance(value, str) and value:
123
124
  return value
125
+ info = payload.get("info")
126
+ if isinstance(info, dict):
127
+ for key in ("sessionID", "sessionId", "session_id"):
128
+ value = info.get(key)
129
+ if isinstance(value, str) and value:
130
+ return value
124
131
  if allow_fallback_id:
125
132
  value = payload.get("id")
126
133
  if isinstance(value, str) and value:
127
134
  return value
128
135
  properties = payload.get("properties")
129
136
  if isinstance(properties, dict):
130
- value = properties.get("sessionID")
131
- if isinstance(value, str) and value:
132
- return value
133
- part = properties.get("part")
134
- if isinstance(part, dict):
135
- value = part.get("sessionID")
137
+ for key in ("sessionID", "sessionId", "session_id"):
138
+ value = properties.get(key)
136
139
  if isinstance(value, str) and value:
137
140
  return value
141
+ info = properties.get("info")
142
+ if isinstance(info, dict):
143
+ for key in ("sessionID", "sessionId", "session_id"):
144
+ value = info.get(key)
145
+ if isinstance(value, str) and value:
146
+ return value
147
+ part = properties.get("part")
148
+ if isinstance(part, dict):
149
+ for key in ("sessionID", "sessionId", "session_id"):
150
+ value = part.get(key)
151
+ if isinstance(value, str) and value:
152
+ return value
138
153
  session = payload.get("session")
139
154
  if isinstance(session, dict):
140
- return extract_session_id(session, allow_fallback_id=allow_fallback_id)
155
+ return extract_session_id(session, allow_fallback_id=True)
141
156
  return None
142
157
 
143
158
 
@@ -676,7 +691,7 @@ async def opencode_missing_env(
676
691
  providers = [entry for entry in payload if isinstance(entry, dict)]
677
692
  for provider in providers:
678
693
  pid = provider.get("id") or provider.get("providerID")
679
- if pid != provider_id:
694
+ if not pid or pid != provider_id:
680
695
  continue
681
696
  if _provider_has_auth(pid, workspace_root):
682
697
  return []
@@ -732,6 +747,7 @@ async def collect_opencode_output_from_events(
732
747
  events: Optional[AsyncIterator[SSEEvent]] = None,
733
748
  *,
734
749
  session_id: str,
750
+ model_payload: Optional[dict[str, str]] = None,
735
751
  progress_session_ids: Optional[set[str]] = None,
736
752
  permission_policy: str = PERMISSION_ALLOW,
737
753
  permission_handler: Optional[PermissionHandler] = None,
@@ -753,19 +769,23 @@ async def collect_opencode_output_from_events(
753
769
  error: Optional[str] = None
754
770
  message_roles: dict[str, str] = {}
755
771
  message_roles_seen = False
756
- last_role_seen: Optional[str] = None
757
772
  pending_text: dict[str, list[str]] = {}
773
+ pending_no_id: list[str] = []
774
+ no_id_role: Optional[str] = None
758
775
  fallback_message: Optional[tuple[Optional[str], Optional[str], str]] = None
759
776
  last_usage_total: Optional[int] = None
760
777
  last_context_window: Optional[int] = None
761
778
  part_types: dict[str, str] = {}
762
- seen_question_request_ids: set[tuple[str, str]] = set()
779
+ seen_question_request_ids: set[tuple[Optional[str], str]] = set()
763
780
  logged_permission_errors: set[str] = set()
764
781
  normalized_question_policy = _normalize_question_policy(question_policy)
765
782
  logger = logging.getLogger(__name__)
766
783
  providers_cache: Optional[list[dict[str, Any]]] = None
767
784
  context_window_cache: dict[str, Optional[int]] = {}
768
785
  session_model_ids: Optional[tuple[Optional[str], Optional[str]]] = None
786
+ default_model_ids = (
787
+ _extract_model_ids(model_payload) if isinstance(model_payload, dict) else None
788
+ )
769
789
 
770
790
  def _message_id_from_info(info: Any) -> Optional[str]:
771
791
  if not isinstance(info, dict):
@@ -786,7 +806,7 @@ async def collect_opencode_output_from_events(
786
806
  return None
787
807
 
788
808
  def _register_message_role(payload: Any) -> tuple[Optional[str], Optional[str]]:
789
- nonlocal last_role_seen, message_roles_seen
809
+ nonlocal message_roles_seen
790
810
  if not isinstance(payload, dict):
791
811
  return None, None
792
812
  info = payload.get("info")
@@ -799,18 +819,27 @@ async def collect_opencode_output_from_events(
799
819
  if isinstance(role, str) and msg_id:
800
820
  message_roles[msg_id] = role
801
821
  message_roles_seen = True
802
- last_role_seen = role
803
822
  return msg_id, role if isinstance(role, str) else None
804
823
 
824
+ def _flush_pending_no_id_as_assistant() -> None:
825
+ nonlocal no_id_role
826
+ if pending_no_id:
827
+ text_parts.extend(pending_no_id)
828
+ pending_no_id.clear()
829
+ no_id_role = "assistant"
830
+
831
+ def _discard_pending_no_id() -> None:
832
+ if pending_no_id:
833
+ pending_no_id.clear()
834
+
805
835
  def _append_text_for_message(message_id: Optional[str], text: str) -> None:
806
836
  if not text:
807
837
  return
808
838
  if message_id is None:
809
- if not message_roles_seen:
810
- text_parts.append(text)
811
- return
812
- if last_role_seen != "user":
839
+ if no_id_role == "assistant":
813
840
  text_parts.append(text)
841
+ else:
842
+ pending_no_id.append(text)
814
843
  return
815
844
  role = message_roles.get(message_id)
816
845
  if role == "user":
@@ -832,26 +861,50 @@ async def collect_opencode_output_from_events(
832
861
  text_parts.extend(pending)
833
862
 
834
863
  def _flush_all_pending_text() -> None:
835
- if not pending_text:
864
+ if pending_text:
865
+ for pending in list(pending_text.values()):
866
+ if pending:
867
+ text_parts.extend(pending)
868
+ pending_text.clear()
869
+ if pending_no_id:
870
+ # If we have not seen a role yet, assume assistant for backwards
871
+ # compatibility with providers that omit roles entirely. Otherwise,
872
+ # only flush when we have already classified no-id text as assistant
873
+ # or when we have no other text (to avoid echoing user prompts).
874
+ if not message_roles_seen or no_id_role == "assistant" or not text_parts:
875
+ text_parts.extend(pending_no_id)
876
+ pending_no_id.clear()
877
+
878
+ def _handle_role_update(message_id: Optional[str], role: Optional[str]) -> None:
879
+ nonlocal no_id_role
880
+ if not role:
881
+ return
882
+ if role == "assistant":
883
+ _flush_pending_text(message_id)
884
+ _flush_pending_no_id_as_assistant()
836
885
  return
837
- for pending in list(pending_text.values()):
838
- if pending:
839
- text_parts.extend(pending)
840
- pending_text.clear()
886
+ if role == "user":
887
+ _flush_pending_text(message_id)
888
+ _discard_pending_no_id()
889
+ no_id_role = None
841
890
 
842
891
  async def _resolve_session_model_ids() -> tuple[Optional[str], Optional[str]]:
843
892
  nonlocal session_model_ids
844
893
  if session_model_ids is not None:
845
894
  return session_model_ids
846
- if session_fetcher is None:
847
- session_model_ids = (None, None)
848
- return session_model_ids
849
- try:
850
- payload = await session_fetcher()
851
- except Exception:
852
- session_model_ids = (None, None)
853
- return session_model_ids
854
- session_model_ids = _extract_model_ids(payload)
895
+ resolved_ids: Optional[tuple[Optional[str], Optional[str]]] = None
896
+ if session_fetcher is not None:
897
+ try:
898
+ payload = await session_fetcher()
899
+ resolved_ids = _extract_model_ids(payload)
900
+ except Exception:
901
+ resolved_ids = None
902
+ # If we failed to resolve model ids from the session (including the empty
903
+ # tuple case), fall back to the caller-provided model payload so we can
904
+ # still backfill usage metadata.
905
+ if not resolved_ids or all(value is None for value in resolved_ids):
906
+ resolved_ids = default_model_ids
907
+ session_model_ids = resolved_ids or (None, None)
855
908
  return session_model_ids
856
909
 
857
910
  async def _resolve_context_window_from_providers(
@@ -946,7 +999,7 @@ async def collect_opencode_output_from_events(
946
999
  await aclose()
947
1000
 
948
1001
  stream_iter = _new_stream().__aiter__()
949
- last_event_at = time.monotonic()
1002
+ last_relevant_event_at = time.monotonic()
950
1003
  last_primary_completion_at: Optional[float] = None
951
1004
  reconnect_attempts = 0
952
1005
  can_reconnect = (
@@ -981,6 +1034,7 @@ async def collect_opencode_output_from_events(
981
1034
  session_id=session_id,
982
1035
  exc=exc,
983
1036
  )
1037
+ idle_seconds = now - last_relevant_event_at
984
1038
  if _status_is_idle(status_type):
985
1039
  log_event(
986
1040
  logger,
@@ -988,9 +1042,9 @@ async def collect_opencode_output_from_events(
988
1042
  "opencode.stream.stalled.session_idle",
989
1043
  session_id=session_id,
990
1044
  status_type=status_type,
991
- idle_seconds=now - last_event_at,
1045
+ idle_seconds=idle_seconds,
992
1046
  )
993
- if not text_parts and pending_text:
1047
+ if not text_parts and (pending_text or pending_no_id):
994
1048
  _flush_all_pending_text()
995
1049
  break
996
1050
  if last_primary_completion_at is not None:
@@ -1000,7 +1054,7 @@ async def collect_opencode_output_from_events(
1000
1054
  "opencode.stream.stalled.after_completion",
1001
1055
  session_id=session_id,
1002
1056
  status_type=status_type,
1003
- idle_seconds=now - last_event_at,
1057
+ idle_seconds=idle_seconds,
1004
1058
  )
1005
1059
  if not can_reconnect:
1006
1060
  break
@@ -1015,7 +1069,7 @@ async def collect_opencode_output_from_events(
1015
1069
  logging.WARNING,
1016
1070
  "opencode.stream.stalled.reconnecting",
1017
1071
  session_id=session_id,
1018
- idle_seconds=now - last_event_at,
1072
+ idle_seconds=idle_seconds,
1019
1073
  backoff_seconds=backoff,
1020
1074
  status_type=status_type,
1021
1075
  attempts=reconnect_attempts,
@@ -1023,21 +1077,86 @@ async def collect_opencode_output_from_events(
1023
1077
  await _close_stream(stream_iter)
1024
1078
  await asyncio.sleep(backoff)
1025
1079
  stream_iter = _new_stream().__aiter__()
1080
+ last_relevant_event_at = now
1026
1081
  continue
1027
- last_event_at = time.monotonic()
1082
+ now = time.monotonic()
1028
1083
  raw = event.data or ""
1029
1084
  try:
1030
1085
  payload = json.loads(raw) if raw else {}
1031
1086
  except json.JSONDecodeError:
1032
1087
  payload = {}
1033
1088
  event_session_id = extract_session_id(payload)
1034
- if not event_session_id:
1035
- continue
1036
- if progress_session_ids is None:
1037
- if event_session_id != session_id:
1038
- continue
1039
- elif event_session_id not in progress_session_ids:
1089
+ is_relevant = False
1090
+ if event_session_id:
1091
+ if progress_session_ids is None:
1092
+ is_relevant = event_session_id == session_id
1093
+ else:
1094
+ is_relevant = event_session_id in progress_session_ids
1095
+ if not is_relevant:
1096
+ if (
1097
+ stall_timeout_seconds is not None
1098
+ and now - last_relevant_event_at > stall_timeout_seconds
1099
+ ):
1100
+ idle_seconds = now - last_relevant_event_at
1101
+ last_relevant_event_at = now
1102
+ status_type = None
1103
+ if session_fetcher is not None:
1104
+ try:
1105
+ payload = await session_fetcher()
1106
+ status_type = _extract_status_type(payload)
1107
+ except Exception as exc:
1108
+ log_event(
1109
+ logger,
1110
+ logging.WARNING,
1111
+ "opencode.session.poll_failed",
1112
+ session_id=session_id,
1113
+ exc=exc,
1114
+ )
1115
+ if _status_is_idle(status_type):
1116
+ log_event(
1117
+ logger,
1118
+ logging.INFO,
1119
+ "opencode.stream.stalled.session_idle",
1120
+ session_id=session_id,
1121
+ status_type=status_type,
1122
+ idle_seconds=idle_seconds,
1123
+ )
1124
+ if not text_parts and (pending_text or pending_no_id):
1125
+ _flush_all_pending_text()
1126
+ break
1127
+ if last_primary_completion_at is not None:
1128
+ log_event(
1129
+ logger,
1130
+ logging.INFO,
1131
+ "opencode.stream.stalled.after_completion",
1132
+ session_id=session_id,
1133
+ status_type=status_type,
1134
+ idle_seconds=idle_seconds,
1135
+ )
1136
+ if not can_reconnect:
1137
+ break
1138
+ backoff_index = min(
1139
+ reconnect_attempts,
1140
+ len(_OPENCODE_STREAM_RECONNECT_BACKOFF_SECONDS) - 1,
1141
+ )
1142
+ backoff = _OPENCODE_STREAM_RECONNECT_BACKOFF_SECONDS[backoff_index]
1143
+ reconnect_attempts += 1
1144
+ log_event(
1145
+ logger,
1146
+ logging.WARNING,
1147
+ "opencode.stream.stalled.reconnecting",
1148
+ session_id=session_id,
1149
+ idle_seconds=idle_seconds,
1150
+ backoff_seconds=backoff,
1151
+ status_type=status_type,
1152
+ attempts=reconnect_attempts,
1153
+ )
1154
+ await _close_stream(stream_iter)
1155
+ await asyncio.sleep(backoff)
1156
+ stream_iter = _new_stream().__aiter__()
1040
1157
  continue
1158
+ last_relevant_event_at = now
1159
+ reconnect_attempts = 0
1041
1160
  is_primary_session = event_session_id == session_id
1042
1161
  if event.event == "question.asked":
1043
1162
  request_id, props = _extract_question_request(payload)
@@ -1219,8 +1338,7 @@ async def collect_opencode_output_from_events(
1219
1338
  if event.event in ("message.updated", "message.completed"):
1220
1339
  if is_primary_session:
1221
1340
  msg_id, role = _register_message_role(payload)
1222
- if role == "assistant":
1223
- _flush_pending_text(msg_id)
1341
+ _handle_role_update(msg_id, role)
1224
1342
  if event.event == "message.part.updated":
1225
1343
  properties = (
1226
1344
  payload.get("properties") if isinstance(payload, dict) else None
@@ -1393,7 +1511,7 @@ async def collect_opencode_output_from_events(
1393
1511
  ):
1394
1512
  if not is_primary_session:
1395
1513
  continue
1396
- if not text_parts and pending_text:
1514
+ if not text_parts and (pending_text or pending_no_id):
1397
1515
  _flush_all_pending_text()
1398
1516
  break
1399
1517
  if event.event == "message.completed" and is_primary_session:
@@ -1408,7 +1526,7 @@ async def collect_opencode_output_from_events(
1408
1526
  resolved_role = message_roles.get(msg_id)
1409
1527
  if resolved_role == "assistant":
1410
1528
  _append_text_for_message(msg_id, text)
1411
- if pending_text:
1529
+ if pending_text or pending_no_id:
1412
1530
  _flush_all_pending_text()
1413
1531
 
1414
1532
  return OpenCodeTurnOutput(text="".join(text_parts).strip(), error=error)
@@ -1419,6 +1537,7 @@ async def collect_opencode_output(
1419
1537
  *,
1420
1538
  session_id: str,
1421
1539
  workspace_path: str,
1540
+ model_payload: Optional[dict[str, str]] = None,
1422
1541
  progress_session_ids: Optional[set[str]] = None,
1423
1542
  permission_policy: str = PERMISSION_ALLOW,
1424
1543
  permission_handler: Optional[PermissionHandler] = None,
@@ -1427,6 +1546,7 @@ async def collect_opencode_output(
1427
1546
  should_stop: Optional[Callable[[], bool]] = None,
1428
1547
  ready_event: Optional[Any] = None,
1429
1548
  part_handler: Optional[PartHandler] = None,
1549
+ stall_timeout_seconds: Optional[float] = _OPENCODE_STREAM_STALL_TIMEOUT_SECONDS,
1430
1550
  ) -> OpenCodeTurnOutput:
1431
1551
  async def _respond(request_id: str, reply: str) -> None:
1432
1552
  await client.respond_permission(request_id=request_id, reply=reply)
@@ -1438,14 +1558,21 @@ async def collect_opencode_output(
1438
1558
  await client.reject_question(request_id)
1439
1559
 
1440
1560
  def _stream_factory() -> AsyncIterator[SSEEvent]:
1441
- return client.stream_events(directory=workspace_path, ready_event=ready_event)
1561
+ return cast(
1562
+ AsyncIterator[SSEEvent],
1563
+ client.stream_events(directory=workspace_path, ready_event=ready_event),
1564
+ )
1442
1565
 
1443
1566
  async def _fetch_session() -> Any:
1444
1567
  statuses = await client.session_status(directory=workspace_path)
1445
1568
  if isinstance(statuses, dict):
1446
1569
  session_status = statuses.get(session_id)
1570
+ if session_status is None:
1571
+ return {"status": {"type": "idle"}}
1447
1572
  if isinstance(session_status, dict):
1448
1573
  return {"status": session_status}
1574
+ if isinstance(session_status, str):
1575
+ return {"status": session_status}
1449
1576
  return {"status": {}}
1450
1577
 
1451
1578
  async def _fetch_providers() -> Any:
@@ -1465,8 +1592,10 @@ async def collect_opencode_output(
1465
1592
  reject_question=_reject_question,
1466
1593
  part_handler=part_handler,
1467
1594
  event_stream_factory=_stream_factory,
1595
+ model_payload=model_payload,
1468
1596
  session_fetcher=_fetch_session,
1469
1597
  provider_fetcher=_fetch_providers,
1598
+ stall_timeout_seconds=stall_timeout_seconds,
1470
1599
  )
1471
1600
 
1472
1601