@team-agent/installer 0.2.11 → 0.3.1

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 (326) hide show
  1. package/Cargo.lock +744 -0
  2. package/Cargo.toml +34 -0
  3. package/crates/team-agent/Cargo.toml +33 -0
  4. package/crates/team-agent/src/cli/adapters.rs +1343 -0
  5. package/crates/team-agent/src/cli/diagnose.rs +554 -0
  6. package/crates/team-agent/src/cli/emit.rs +1204 -0
  7. package/crates/team-agent/src/cli/helpers.rs +88 -0
  8. package/crates/team-agent/src/cli/leader.rs +216 -0
  9. package/crates/team-agent/src/cli/mod.rs +1207 -0
  10. package/crates/team-agent/src/cli/profile.rs +306 -0
  11. package/crates/team-agent/src/cli/send.rs +215 -0
  12. package/crates/team-agent/src/cli/status.rs +179 -0
  13. package/crates/team-agent/src/cli/status_port.rs +502 -0
  14. package/crates/team-agent/src/cli/tests/base.rs +616 -0
  15. package/crates/team-agent/src/cli/tests/compile.rs +96 -0
  16. package/crates/team-agent/src/cli/tests/divergence.rs +509 -0
  17. package/crates/team-agent/src/cli/tests/lane_c.rs +333 -0
  18. package/crates/team-agent/src/cli/tests/leader_watch.rs +395 -0
  19. package/crates/team-agent/src/cli/tests/main_preserved.rs +675 -0
  20. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +390 -0
  21. package/crates/team-agent/src/cli/tests/mod.rs +97 -0
  22. package/crates/team-agent/src/cli/tests/peer_allow.rs +137 -0
  23. package/crates/team-agent/src/cli/tests/repair_state_byte_lock.rs +302 -0
  24. package/crates/team-agent/src/cli/tests/run_delegation.rs +305 -0
  25. package/crates/team-agent/src/cli/tests/status_send.rs +385 -0
  26. package/crates/team-agent/src/cli/tests/verb_profile.rs +182 -0
  27. package/crates/team-agent/src/cli/tests/verb_settle.rs +236 -0
  28. package/crates/team-agent/src/cli/tests/verb_validate.rs +184 -0
  29. package/crates/team-agent/src/cli/types.rs +605 -0
  30. package/crates/team-agent/src/compiler/tests.rs +701 -0
  31. package/crates/team-agent/src/compiler.rs +489 -0
  32. package/crates/team-agent/src/coordinator/backoff.rs +153 -0
  33. package/crates/team-agent/src/coordinator/health.rs +557 -0
  34. package/crates/team-agent/src/coordinator/mod.rs +80 -0
  35. package/crates/team-agent/src/coordinator/orphan.rs +179 -0
  36. package/crates/team-agent/src/coordinator/tests/abnormal.rs +255 -0
  37. package/crates/team-agent/src/coordinator/tests/basics.rs +262 -0
  38. package/crates/team-agent/src/coordinator/tests/daemon.rs +323 -0
  39. package/crates/team-agent/src/coordinator/tests/health_sync.rs +263 -0
  40. package/crates/team-agent/src/coordinator/tests/main_preserved.rs +136 -0
  41. package/crates/team-agent/src/coordinator/tests/mod.rs +310 -0
  42. package/crates/team-agent/src/coordinator/tests/spine.rs +261 -0
  43. package/crates/team-agent/src/coordinator/tests/takeover.rs +227 -0
  44. package/crates/team-agent/src/coordinator/tests/tick_core.rs +256 -0
  45. package/crates/team-agent/src/coordinator/tests/watch.rs +167 -0
  46. package/crates/team-agent/src/coordinator/tick.rs +2032 -0
  47. package/crates/team-agent/src/coordinator/types.rs +584 -0
  48. package/crates/team-agent/src/db/migration.rs +716 -0
  49. package/crates/team-agent/src/db/mod.rs +23 -0
  50. package/crates/team-agent/src/db/schema.rs +378 -0
  51. package/crates/team-agent/src/event_log.rs +375 -0
  52. package/crates/team-agent/src/fake_worker.rs +253 -0
  53. package/crates/team-agent/src/leader/helpers.rs +190 -0
  54. package/crates/team-agent/src/leader/inject.rs +33 -0
  55. package/crates/team-agent/src/leader/lease.rs +1084 -0
  56. package/crates/team-agent/src/leader/mod.rs +99 -0
  57. package/crates/team-agent/src/leader/owner_bind.rs +292 -0
  58. package/crates/team-agent/src/leader/rediscover/tests.rs +526 -0
  59. package/crates/team-agent/src/leader/rediscover.rs +1101 -0
  60. package/crates/team-agent/src/leader/start.rs +273 -0
  61. package/crates/team-agent/src/leader/takeover.rs +235 -0
  62. package/crates/team-agent/src/leader/tests/basics.rs +183 -0
  63. package/crates/team-agent/src/leader/tests/byte_findings.rs +237 -0
  64. package/crates/team-agent/src/leader/tests/identity.rs +206 -0
  65. package/crates/team-agent/src/leader/tests/idle.rs +272 -0
  66. package/crates/team-agent/src/leader/tests/lease_api.rs +225 -0
  67. package/crates/team-agent/src/leader/tests/lease_claim.rs +410 -0
  68. package/crates/team-agent/src/leader/tests/mod.rs +125 -0
  69. package/crates/team-agent/src/leader/tests/rediscover.rs +351 -0
  70. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +204 -0
  71. package/crates/team-agent/src/leader/types.rs +489 -0
  72. package/crates/team-agent/src/lib.rs +85 -0
  73. package/crates/team-agent/src/lifecycle/display.rs +228 -0
  74. package/crates/team-agent/src/lifecycle/helpers.rs +112 -0
  75. package/crates/team-agent/src/lifecycle/launch/plan.rs +227 -0
  76. package/crates/team-agent/src/lifecycle/launch.rs +2109 -0
  77. package/crates/team-agent/src/lifecycle/mod.rs +62 -0
  78. package/crates/team-agent/src/lifecycle/restart/agent.rs +533 -0
  79. package/crates/team-agent/src/lifecycle/restart/common.rs +517 -0
  80. package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +41 -0
  81. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +268 -0
  82. package/crates/team-agent/src/lifecycle/restart/remove.rs +780 -0
  83. package/crates/team-agent/src/lifecycle/restart/selection.rs +208 -0
  84. package/crates/team-agent/src/lifecycle/restart/team_state.rs +242 -0
  85. package/crates/team-agent/src/lifecycle/restart.rs +76 -0
  86. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +455 -0
  87. package/crates/team-agent/src/lifecycle/tests/core.rs +989 -0
  88. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +583 -0
  89. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +985 -0
  90. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +265 -0
  91. package/crates/team-agent/src/lifecycle/tests.rs +27 -0
  92. package/crates/team-agent/src/lifecycle/types.rs +710 -0
  93. package/crates/team-agent/src/main.rs +41 -0
  94. package/crates/team-agent/src/mcp_server/helpers.rs +228 -0
  95. package/crates/team-agent/src/mcp_server/mod.rs +183 -0
  96. package/crates/team-agent/src/mcp_server/normalize.rs +312 -0
  97. package/crates/team-agent/src/mcp_server/tests/golden.rs +283 -0
  98. package/crates/team-agent/src/mcp_server/tests/normalize.rs +244 -0
  99. package/crates/team-agent/src/mcp_server/tests/scoped.rs +189 -0
  100. package/crates/team-agent/src/mcp_server/tests/send.rs +222 -0
  101. package/crates/team-agent/src/mcp_server/tests/tools.rs +158 -0
  102. package/crates/team-agent/src/mcp_server/tests/wire.rs +187 -0
  103. package/crates/team-agent/src/mcp_server/tests.rs +38 -0
  104. package/crates/team-agent/src/mcp_server/tools.rs +603 -0
  105. package/crates/team-agent/src/mcp_server/types.rs +421 -0
  106. package/crates/team-agent/src/mcp_server/wire.rs +468 -0
  107. package/crates/team-agent/src/message_store.rs +767 -0
  108. package/crates/team-agent/src/messaging/activity.rs +433 -0
  109. package/crates/team-agent/src/messaging/delivery.rs +743 -0
  110. package/crates/team-agent/src/messaging/helpers.rs +209 -0
  111. package/crates/team-agent/src/messaging/leader_receiver.rs +329 -0
  112. package/crates/team-agent/src/messaging/mod.rs +147 -0
  113. package/crates/team-agent/src/messaging/peers.rs +32 -0
  114. package/crates/team-agent/src/messaging/results.rs +553 -0
  115. package/crates/team-agent/src/messaging/scheduler.rs +344 -0
  116. package/crates/team-agent/src/messaging/selftest.rs +100 -0
  117. package/crates/team-agent/src/messaging/send.rs +578 -0
  118. package/crates/team-agent/src/messaging/tests/basic.rs +357 -0
  119. package/crates/team-agent/src/messaging/tests/main_preserved.rs +122 -0
  120. package/crates/team-agent/src/messaging/tests/mod.rs +293 -0
  121. package/crates/team-agent/src/messaging/tests/runtime.rs +1422 -0
  122. package/crates/team-agent/src/messaging/tests/spine.rs +437 -0
  123. package/crates/team-agent/src/messaging/trust.rs +192 -0
  124. package/crates/team-agent/src/messaging/types.rs +355 -0
  125. package/crates/team-agent/src/messaging/watchers.rs +591 -0
  126. package/crates/team-agent/src/model/enums.rs +311 -0
  127. package/crates/team-agent/src/model/errors.rs +17 -0
  128. package/crates/team-agent/src/model/ids.rs +155 -0
  129. package/crates/team-agent/src/model/mod.rs +22 -0
  130. package/crates/team-agent/src/model/paths.rs +228 -0
  131. package/crates/team-agent/src/model/permissions.rs +567 -0
  132. package/crates/team-agent/src/model/routing.rs +340 -0
  133. package/crates/team-agent/src/model/spec.rs +680 -0
  134. package/crates/team-agent/src/model/task_graph.rs +380 -0
  135. package/crates/team-agent/src/model/testdata/fuzz.golden.yaml +43 -0
  136. package/crates/team-agent/src/model/testdata/fuzz.yaml +43 -0
  137. package/crates/team-agent/src/model/testdata/spec_invalid_a.yaml +207 -0
  138. package/crates/team-agent/src/model/testdata/team.spec.golden.yaml +206 -0
  139. package/crates/team-agent/src/model/testdata/team.spec.yaml +206 -0
  140. package/crates/team-agent/src/model/yaml/tests.rs +288 -0
  141. package/crates/team-agent/src/model/yaml.rs +800 -0
  142. package/crates/team-agent/src/packaging/install.rs +305 -0
  143. package/crates/team-agent/src/packaging/migrate.rs +30 -0
  144. package/crates/team-agent/src/packaging/mod.rs +82 -0
  145. package/crates/team-agent/src/packaging/repair.rs +24 -0
  146. package/crates/team-agent/src/packaging/tests.rs +829 -0
  147. package/crates/team-agent/src/packaging/types.rs +369 -0
  148. package/crates/team-agent/src/provider/adapter.rs +801 -0
  149. package/crates/team-agent/src/provider/approvals/mod.rs +2 -0
  150. package/crates/team-agent/src/provider/approvals/parsing.rs +452 -0
  151. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +163 -0
  152. package/crates/team-agent/src/provider/classify.rs +456 -0
  153. package/crates/team-agent/src/provider/faults.rs +136 -0
  154. package/crates/team-agent/src/provider/helpers.rs +41 -0
  155. package/crates/team-agent/src/provider/mod.rs +53 -0
  156. package/crates/team-agent/src/provider/startup_prompt.rs +423 -0
  157. package/crates/team-agent/src/provider/tests/adapter.rs +239 -0
  158. package/crates/team-agent/src/provider/tests/classify.rs +240 -0
  159. package/crates/team-agent/src/provider/tests/faults.rs +120 -0
  160. package/crates/team-agent/src/provider/tests/idle.rs +208 -0
  161. package/crates/team-agent/src/provider/tests/wire.rs +213 -0
  162. package/crates/team-agent/src/provider/tests.rs +31 -0
  163. package/crates/team-agent/src/provider/types.rs +424 -0
  164. package/crates/team-agent/src/state/identity.rs +659 -0
  165. package/crates/team-agent/src/state/mod.rs +58 -0
  166. package/crates/team-agent/src/state/owner_gate.rs +423 -0
  167. package/crates/team-agent/src/state/persist.rs +712 -0
  168. package/crates/team-agent/src/state/projection.rs +657 -0
  169. package/crates/team-agent/src/state/selector.rs +105 -0
  170. package/crates/team-agent/src/state/testdata/state-rich.canonical.json +133 -0
  171. package/crates/team-agent/src/tmux_backend/tests.rs +765 -0
  172. package/crates/team-agent/src/tmux_backend.rs +810 -0
  173. package/crates/team-agent/src/transport/test_support.rs +252 -0
  174. package/crates/team-agent/src/transport/tests/behavior.rs +327 -0
  175. package/crates/team-agent/src/transport/tests/mod.rs +199 -0
  176. package/crates/team-agent/src/transport/tests/wire.rs +527 -0
  177. package/crates/team-agent/src/transport.rs +774 -0
  178. package/npm/install.mjs +118 -112
  179. package/package.json +15 -13
  180. package/crates/team-agent-core/Cargo.toml +0 -12
  181. package/crates/team-agent-core/src/lib.rs +0 -332
  182. package/crates/team-agent-core/src/main.rs +0 -152
  183. package/pyproject.toml +0 -18
  184. package/scripts/install.py +0 -88
  185. package/scripts/run_regression_tests.py +0 -83
  186. package/src/team_agent/__init__.py +0 -3
  187. package/src/team_agent/__main__.py +0 -5
  188. package/src/team_agent/_legacy_pane_discovery.py +0 -186
  189. package/src/team_agent/abnormal_track.py +0 -253
  190. package/src/team_agent/approvals/__init__.py +0 -65
  191. package/src/team_agent/approvals/constants.py +0 -6
  192. package/src/team_agent/approvals/parsing.py +0 -176
  193. package/src/team_agent/approvals/runtime_prompts.py +0 -171
  194. package/src/team_agent/approvals/status.py +0 -176
  195. package/src/team_agent/cli/__init__.py +0 -137
  196. package/src/team_agent/cli/commands.py +0 -481
  197. package/src/team_agent/cli/e2e.py +0 -202
  198. package/src/team_agent/cli/helpers.py +0 -226
  199. package/src/team_agent/cli/parser.py +0 -540
  200. package/src/team_agent/compiler.py +0 -334
  201. package/src/team_agent/coordinator/__init__.py +0 -53
  202. package/src/team_agent/coordinator/__main__.py +0 -119
  203. package/src/team_agent/coordinator/lifecycle.py +0 -411
  204. package/src/team_agent/coordinator/metadata.py +0 -61
  205. package/src/team_agent/coordinator/paths.py +0 -17
  206. package/src/team_agent/diagnose/__init__.py +0 -48
  207. package/src/team_agent/diagnose/checks.py +0 -101
  208. package/src/team_agent/diagnose/comms.py +0 -213
  209. package/src/team_agent/diagnose/health.py +0 -241
  210. package/src/team_agent/diagnose/orphan_cleanup.py +0 -364
  211. package/src/team_agent/diagnose/preflight.py +0 -194
  212. package/src/team_agent/diagnose/quick_start.py +0 -324
  213. package/src/team_agent/display/__init__.py +0 -92
  214. package/src/team_agent/display/adaptive.py +0 -511
  215. package/src/team_agent/display/backend.py +0 -46
  216. package/src/team_agent/display/close.py +0 -154
  217. package/src/team_agent/display/ghostty.py +0 -77
  218. package/src/team_agent/display/rebuild.py +0 -102
  219. package/src/team_agent/display/tiling.py +0 -156
  220. package/src/team_agent/display/worker_window.py +0 -114
  221. package/src/team_agent/display/workspace.py +0 -382
  222. package/src/team_agent/errors.py +0 -10
  223. package/src/team_agent/events.py +0 -84
  224. package/src/team_agent/fake_worker.py +0 -80
  225. package/src/team_agent/idle_predicate.py +0 -218
  226. package/src/team_agent/idle_takeover.py +0 -59
  227. package/src/team_agent/idle_takeover_wiring.py +0 -114
  228. package/src/team_agent/launch/__init__.py +0 -41
  229. package/src/team_agent/launch/bootstrap.py +0 -85
  230. package/src/team_agent/launch/config.py +0 -106
  231. package/src/team_agent/launch/core.py +0 -301
  232. package/src/team_agent/launch/requirements.py +0 -57
  233. package/src/team_agent/leader/__init__.py +0 -926
  234. package/src/team_agent/leader_binding.py +0 -183
  235. package/src/team_agent/lifecycle/__init__.py +0 -5
  236. package/src/team_agent/lifecycle/agents.py +0 -278
  237. package/src/team_agent/lifecycle/operations.py +0 -411
  238. package/src/team_agent/lifecycle/paste_buffer_hygiene.py +0 -39
  239. package/src/team_agent/lifecycle/start.py +0 -363
  240. package/src/team_agent/mcp_server/__init__.py +0 -42
  241. package/src/team_agent/mcp_server/__main__.py +0 -7
  242. package/src/team_agent/mcp_server/contracts.py +0 -148
  243. package/src/team_agent/mcp_server/normalize.py +0 -257
  244. package/src/team_agent/mcp_server/server.py +0 -150
  245. package/src/team_agent/mcp_server/tools.py +0 -352
  246. package/src/team_agent/message_store/__init__.py +0 -23
  247. package/src/team_agent/message_store/agent_health.py +0 -113
  248. package/src/team_agent/message_store/core.py +0 -497
  249. package/src/team_agent/message_store/leader_notification_log.py +0 -198
  250. package/src/team_agent/message_store/result_watchers.py +0 -251
  251. package/src/team_agent/message_store/schema.py +0 -308
  252. package/src/team_agent/message_store/schema_migration.py +0 -448
  253. package/src/team_agent/messaging/__init__.py +0 -1
  254. package/src/team_agent/messaging/activity_detector.py +0 -262
  255. package/src/team_agent/messaging/delivery.py +0 -504
  256. package/src/team_agent/messaging/deps.py +0 -247
  257. package/src/team_agent/messaging/idle_alerts.py +0 -423
  258. package/src/team_agent/messaging/internal_delivery.py +0 -46
  259. package/src/team_agent/messaging/leader.py +0 -497
  260. package/src/team_agent/messaging/leader_api_errors.py +0 -216
  261. package/src/team_agent/messaging/leader_panes.py +0 -673
  262. package/src/team_agent/messaging/owner_bypass.py +0 -29
  263. package/src/team_agent/messaging/result_delivery.py +0 -539
  264. package/src/team_agent/messaging/results.py +0 -447
  265. package/src/team_agent/messaging/scheduler.py +0 -450
  266. package/src/team_agent/messaging/send.py +0 -532
  267. package/src/team_agent/messaging/session_drift.py +0 -94
  268. package/src/team_agent/messaging/tmux_io.py +0 -506
  269. package/src/team_agent/messaging/tmux_prompt.py +0 -338
  270. package/src/team_agent/messaging/trust_auto_answer.py +0 -52
  271. package/src/team_agent/orchestrator/__init__.py +0 -376
  272. package/src/team_agent/orchestrator/plan.py +0 -122
  273. package/src/team_agent/orchestrator/state.py +0 -128
  274. package/src/team_agent/paths.py +0 -45
  275. package/src/team_agent/permissions.py +0 -123
  276. package/src/team_agent/profiles/__init__.py +0 -82
  277. package/src/team_agent/profiles/constants.py +0 -19
  278. package/src/team_agent/profiles/core.py +0 -407
  279. package/src/team_agent/profiles/helpers.py +0 -69
  280. package/src/team_agent/profiles/provider_env.py +0 -188
  281. package/src/team_agent/profiles/smoke.py +0 -201
  282. package/src/team_agent/provider_cli/__init__.py +0 -43
  283. package/src/team_agent/provider_cli/adapter.py +0 -172
  284. package/src/team_agent/provider_cli/base.py +0 -48
  285. package/src/team_agent/provider_cli/claude.py +0 -503
  286. package/src/team_agent/provider_cli/codex.py +0 -336
  287. package/src/team_agent/provider_cli/copilot.py +0 -8
  288. package/src/team_agent/provider_cli/fake.py +0 -39
  289. package/src/team_agent/provider_cli/gemini.py +0 -95
  290. package/src/team_agent/provider_cli/opencode.py +0 -8
  291. package/src/team_agent/provider_cli/prompt.py +0 -62
  292. package/src/team_agent/provider_cli/registry.py +0 -18
  293. package/src/team_agent/provider_cli/unsupported.py +0 -32
  294. package/src/team_agent/provider_state/README.md +0 -78
  295. package/src/team_agent/provider_state/__init__.py +0 -91
  296. package/src/team_agent/provider_state/claude.py +0 -86
  297. package/src/team_agent/provider_state/codex.py +0 -84
  298. package/src/team_agent/provider_state/common.py +0 -207
  299. package/src/team_agent/provider_state/registry.py +0 -118
  300. package/src/team_agent/providers.py +0 -163
  301. package/src/team_agent/quality_gates.py +0 -104
  302. package/src/team_agent/restart/__init__.py +0 -34
  303. package/src/team_agent/restart/orchestration.py +0 -554
  304. package/src/team_agent/restart/selection.py +0 -89
  305. package/src/team_agent/restart/snapshot.py +0 -70
  306. package/src/team_agent/routing.py +0 -84
  307. package/src/team_agent/runtime.py +0 -1243
  308. package/src/team_agent/rust_core.py +0 -327
  309. package/src/team_agent/sessions/__init__.py +0 -25
  310. package/src/team_agent/sessions/capture.py +0 -144
  311. package/src/team_agent/sessions/inventory.py +0 -44
  312. package/src/team_agent/sessions/resume.py +0 -135
  313. package/src/team_agent/simple_yaml.py +0 -236
  314. package/src/team_agent/spec.py +0 -370
  315. package/src/team_agent/state.py +0 -693
  316. package/src/team_agent/status/__init__.py +0 -63
  317. package/src/team_agent/status/approvals.py +0 -52
  318. package/src/team_agent/status/compact.py +0 -158
  319. package/src/team_agent/status/constants.py +0 -18
  320. package/src/team_agent/status/inbox.py +0 -58
  321. package/src/team_agent/status/peek.py +0 -117
  322. package/src/team_agent/status/queries.py +0 -199
  323. package/src/team_agent/task_graph.py +0 -80
  324. package/src/team_agent/terminal.py +0 -57
  325. package/src/team_agent/wake.py +0 -58
  326. package/src/team_agent/watch/__init__.py +0 -145
@@ -0,0 +1,240 @@
1
+ fn claude_end_turn() -> String {
2
+ r#"{"type":"assistant","requestId":"r1","message":{"stop_reason":"end_turn"}}"#.to_string()
3
+ }
4
+ fn claude_open_turn() -> String {
5
+ r#"{"type":"assistant","requestId":"r1","message":{"stop_reason":"tool_use"}}"#.to_string()
6
+ }
7
+
8
+ #[test]
9
+ fn classify_empty_text_is_unknown_never_idle() {
10
+ // probe empty_text: state=unknown turn_id=None reason=unreadable_or_empty
11
+ // source=session_file. BLOOD-LINE: unknown is NEVER idle (bug-071/077/085).
12
+ let c = classify(Provider::ClaudeCode, "", ProcessLiveness::Unverifiable, 0.0)
13
+ .expect("classify ok");
14
+ assert_eq!(c.state, TurnState::Unknown);
15
+ assert_eq!(c.reason, "unreadable_or_empty");
16
+ assert_eq!(c.turn_id, None);
17
+ assert_eq!(c.source, ClassifySource::SessionFile);
18
+ // The pin the audit demands: classify(unreadable) MUST NOT be treatable as idle.
19
+ assert!(
20
+ !c.state.is_idle_for_takeover(),
21
+ "unreadable/empty input must never be idle (C5)"
22
+ );
23
+ }
24
+
25
+ #[test]
26
+ fn classify_whitespace_only_is_unknown() {
27
+ // probe whitespace_only → unknown / unreadable_or_empty.
28
+ let c = classify(Provider::Claude, " \n \t \n", ProcessLiveness::Unverifiable, 0.0)
29
+ .expect("classify ok");
30
+ assert_eq!(c.state, TurnState::Unknown);
31
+ assert_eq!(c.reason, "unreadable_or_empty");
32
+ assert!(!c.state.is_idle_for_takeover());
33
+ }
34
+
35
+ #[test]
36
+ fn classify_garbage_jsonl_is_unknown_not_idle() {
37
+ // probe garbage_jsonl ("not json\n{broken"): parse yields diagnostics but
38
+ // had_records=false → reason=unreadable_or_empty (the !had_records branch
39
+ // wins over diagnostics; common.py:72-75). NEVER idle.
40
+ let c = classify(Provider::Claude, "not json\n{broken", ProcessLiveness::Unverifiable, 0.0)
41
+ .expect("classify ok");
42
+ assert_eq!(c.state, TurnState::Unknown);
43
+ assert_eq!(c.reason, "unreadable_or_empty");
44
+ assert!(!c.state.is_idle_for_takeover());
45
+ }
46
+
47
+ #[test]
48
+ fn classify_unrecognized_format_is_unknown() {
49
+ // probe '{"foo":"bar"}': had_records=true, no lifecycle fact, NO diagnostics
50
+ // → reason=no_turn_lifecycle_fact (NOT unrecognized_format — golden-confirmed).
51
+ let c = classify(Provider::Claude, r#"{"foo":"bar"}"#, ProcessLiveness::Unverifiable, 0.0)
52
+ .expect("classify ok");
53
+ assert_eq!(c.state, TurnState::Unknown);
54
+ assert_eq!(c.reason, "no_turn_lifecycle_fact");
55
+ assert!(!c.state.is_idle_for_takeover());
56
+ }
57
+
58
+ #[test]
59
+ fn classify_claude_code_alias_normalizes_to_claude_reader() {
60
+ // 陷阱 #4: claude_code → claude reader (__init__.py:88). Both must classify an
61
+ // end_turn transcript identically to idle / end_turn — the alias never dies.
62
+ let txt = claude_end_turn();
63
+ let alias = classify(Provider::ClaudeCode, &txt, ProcessLiveness::Unverifiable, 0.0)
64
+ .expect("alias ok");
65
+ let canon = classify(Provider::Claude, &txt, ProcessLiveness::Unverifiable, 0.0)
66
+ .expect("canon ok");
67
+ assert_eq!(alias.state, TurnState::Idle);
68
+ assert_eq!(alias.reason, "end_turn");
69
+ assert_eq!(alias, canon, "claude_code must normalize to the claude reader");
70
+ }
71
+
72
+ #[test]
73
+ fn classify_open_turn_with_no_process_is_unknown_not_working() {
74
+ // probe open_turn_process_none: open turn + Unverifiable (None process) →
75
+ // unknown / process_identity_unverified / source=process_guard, turn_id=r1.
76
+ // C4: missing identity is NEVER optimistically working.
77
+ let c = classify(Provider::Claude, &claude_open_turn(), ProcessLiveness::Unverifiable, 0.0)
78
+ .expect("classify ok");
79
+ assert_eq!(c.state, TurnState::Unknown);
80
+ assert_eq!(c.reason, "process_identity_unverified");
81
+ assert_eq!(c.source, ClassifySource::ProcessGuard);
82
+ assert_eq!(c.turn_id, Some(TurnId::new("r1")));
83
+ assert!(!c.state.is_idle_for_takeover());
84
+ }
85
+
86
+ #[test]
87
+ fn classify_open_turn_alive_is_working() {
88
+ // probe open_turn_alive: open turn + Alive → working / open_turn / source=session_file.
89
+ let c = classify(Provider::Claude, &claude_open_turn(), ProcessLiveness::Alive, 0.0)
90
+ .expect("classify ok");
91
+ assert_eq!(c.state, TurnState::Working);
92
+ assert_eq!(c.reason, "open_turn");
93
+ assert_eq!(c.source, ClassifySource::SessionFile);
94
+ assert_eq!(c.turn_id, Some(TurnId::new("r1")));
95
+ }
96
+
97
+ #[test]
98
+ fn classify_open_turn_dead_is_abnormal_crashed_mid_turn() {
99
+ // probe open_turn_dead: open turn + Dead → abnormal / crashed_mid_turn /
100
+ // source=process_guard, annotations contains "crashed_mid_turn".
101
+ let c = classify(Provider::Claude, &claude_open_turn(), ProcessLiveness::Dead, 0.0)
102
+ .expect("classify ok");
103
+ assert_eq!(c.state, TurnState::Abnormal);
104
+ assert_eq!(c.reason, "crashed_mid_turn");
105
+ assert_eq!(c.source, ClassifySource::ProcessGuard);
106
+ assert!(c.annotations.contains(&"crashed_mid_turn".to_string()));
107
+ }
108
+
109
+ #[test]
110
+ fn classify_open_turn_unverifiable_process_is_unknown() {
111
+ // probe open_turn_unverifiable: open turn + Unverifiable →
112
+ // unknown / process_identity_unverified (NOT working). NEVER idle.
113
+ let c = classify(Provider::Claude, &claude_open_turn(), ProcessLiveness::Unverifiable, 0.0)
114
+ .expect("classify ok");
115
+ assert_eq!(c.state, TurnState::Unknown);
116
+ assert_eq!(c.reason, "process_identity_unverified");
117
+ assert!(!c.state.is_idle_for_takeover());
118
+ }
119
+
120
+ #[test]
121
+ fn classify_last_lifecycle_fact_wins() {
122
+ // probe last_fact_wins_complete (tool_use 'a' then end_turn 'b') →
123
+ // idle / end_turn, turn_id = the LAST lifecycle fact's id ("b").
124
+ let two = format!(
125
+ "{}\n{}",
126
+ r#"{"type":"assistant","requestId":"a","message":{"stop_reason":"tool_use"}}"#,
127
+ r#"{"type":"assistant","requestId":"b","message":{"stop_reason":"end_turn"}}"#,
128
+ );
129
+ let c = classify(Provider::Claude, &two, ProcessLiveness::Unverifiable, 0.0)
130
+ .expect("classify ok");
131
+ assert_eq!(c.state, TurnState::Idle);
132
+ assert_eq!(c.reason, "end_turn");
133
+ assert_eq!(c.turn_id, Some(TurnId::new("b")));
134
+ }
135
+
136
+ #[test]
137
+ fn classify_c14_open_turn_beats_silence() {
138
+ // probe c14_open_after_complete_alive (end_turn 'a' then tool_use 'b', alive,
139
+ // file_silence=9999) → working / open_turn, turn_id="b". Silence is DISCARDED:
140
+ // only a dead process guard can demote an open turn (common.py:93-103, C14).
141
+ let c14 = format!(
142
+ "{}\n{}",
143
+ r#"{"type":"assistant","requestId":"a","message":{"stop_reason":"end_turn"}}"#,
144
+ r#"{"type":"assistant","requestId":"b","message":{"stop_reason":"tool_use"}}"#,
145
+ );
146
+ let c = classify(Provider::Claude, &c14, ProcessLiveness::Alive, 9999.0)
147
+ .expect("classify ok");
148
+ assert_eq!(c.state, TurnState::Working);
149
+ assert_eq!(c.reason, "open_turn");
150
+ assert_eq!(c.turn_id, Some(TurnId::new("b")));
151
+ }
152
+
153
+ #[test]
154
+ fn classify_claude_interrupted_is_idle_interrupted_annotated() {
155
+ // probe claude_interrupted ("[Request interrupted by user]") →
156
+ // idle_interrupted / user_interrupt / annotations=["interrupted"], turn_id=u1.
157
+ let txt = r#"{"type":"user","uuid":"u1","message":{"content":[{"type":"text","text":"[Request interrupted by user]"}]}}"#;
158
+ let c = classify(Provider::Claude, txt, ProcessLiveness::Unverifiable, 0.0)
159
+ .expect("classify ok");
160
+ assert_eq!(c.state, TurnState::IdleInterrupted);
161
+ assert_eq!(c.reason, "user_interrupt");
162
+ assert_eq!(c.annotations, vec!["interrupted".to_string()]);
163
+ assert_eq!(c.turn_id, Some(TurnId::new("u1")));
164
+ // C12: idle_interrupted IS idle for take-over (annotated).
165
+ assert!(c.state.is_idle_for_takeover());
166
+ }
167
+
168
+ #[test]
169
+ fn classify_claude_stop_sequence_is_idle() {
170
+ // probe claude_stop_sequence → idle / stop_sequence.
171
+ let txt = r#"{"type":"assistant","requestId":"r1","message":{"stop_reason":"stop_sequence"}}"#;
172
+ let c = classify(Provider::Claude, txt, ProcessLiveness::Unverifiable, 0.0)
173
+ .expect("classify ok");
174
+ assert_eq!(c.state, TurnState::Idle);
175
+ assert_eq!(c.reason, "stop_sequence");
176
+ }
177
+
178
+ #[test]
179
+ fn classify_codex_task_complete_is_idle() {
180
+ // probe codex_task_complete → idle / task_complete, turn_id from payload (ct1).
181
+ let txt = r#"{"type":"event_msg","payload":{"type":"task_complete","turn_id":"ct1"}}"#;
182
+ let c = classify(Provider::Codex, txt, ProcessLiveness::Unverifiable, 0.0)
183
+ .expect("classify ok");
184
+ assert_eq!(c.state, TurnState::Idle);
185
+ assert_eq!(c.reason, "task_complete");
186
+ assert_eq!(c.turn_id, Some(TurnId::new("ct1")));
187
+ }
188
+
189
+ #[test]
190
+ fn classify_codex_turn_aborted_interrupted_is_idle_interrupted() {
191
+ // probe codex_turn_aborted_interrupted → idle_interrupted / interrupted.
192
+ let interrupted = r#"{"type":"event_msg","payload":{"type":"turn_aborted","turn_id":"ct2","reason":"interrupted"}}"#;
193
+ let c = classify(Provider::Codex, interrupted, ProcessLiveness::Unverifiable, 0.0)
194
+ .expect("classify ok");
195
+ assert_eq!(c.state, TurnState::IdleInterrupted);
196
+ assert_eq!(c.reason, "interrupted");
197
+ // probe codex_turn_aborted_other (reason="error") → idle_interrupted with the
198
+ // RAW reason string passed through ("error"), turn_id=ct3.
199
+ let other = r#"{"type":"event_msg","payload":{"type":"turn_aborted","turn_id":"ct3","reason":"error"}}"#;
200
+ let c2 = classify(Provider::Codex, other, ProcessLiveness::Unverifiable, 0.0)
201
+ .expect("classify ok");
202
+ assert_eq!(c2.state, TurnState::IdleInterrupted);
203
+ assert_eq!(c2.reason, "error", "raw abort reason must pass through");
204
+ assert_eq!(c2.turn_id, Some(TurnId::new("ct3")));
205
+ }
206
+
207
+ #[test]
208
+ fn classify_codex_appserver_failed_is_abnormal() {
209
+ // probe codex_appserver_failed (turn.status==failed) → abnormal / turn_failed /
210
+ // annotations=["turn_failed"], source=session_file, turn_id=ct4.
211
+ let txt = r#"{"jsonrpc":"2.0","method":"turn/completed","params":{"turn":{"id":"ct4","status":"failed"}}}"#;
212
+ let c = classify(Provider::Codex, txt, ProcessLiveness::Unverifiable, 0.0)
213
+ .expect("classify ok");
214
+ assert_eq!(c.state, TurnState::Abnormal);
215
+ assert_eq!(c.reason, "turn_failed");
216
+ assert_eq!(c.annotations, vec!["turn_failed".to_string()]);
217
+ assert_eq!(c.turn_id, Some(TurnId::new("ct4")));
218
+ assert!(!c.state.is_idle_for_takeover());
219
+ }
220
+
221
+ #[test]
222
+ fn classify_codex_appserver_approval_is_blocked_on_human() {
223
+ // probe codex_appserver_approval (method endswith requestApproval) →
224
+ // blocked_on_human / approval_required / annotations=["awaiting_approval"], turn_id=ct5.
225
+ let txt = r#"{"jsonrpc":"2.0","method":"session/requestApproval","params":{"turnId":"ct5"}}"#;
226
+ let c = classify(Provider::Codex, txt, ProcessLiveness::Unverifiable, 0.0)
227
+ .expect("classify ok");
228
+ assert_eq!(c.state, TurnState::BlockedOnHuman);
229
+ assert_eq!(c.reason, "approval_required");
230
+ assert_eq!(c.annotations, vec!["awaiting_approval".to_string()]);
231
+ assert_eq!(c.turn_id, Some(TurnId::new("ct5")));
232
+ // blocked_on_human is NOT idle for take-over.
233
+ assert!(!c.state.is_idle_for_takeover());
234
+ }
235
+
236
+ // ---- (b) idle predicate / evaluate_takeover_reminder (NoPingReason cases) ----
237
+ //
238
+ // Golden via /tmp/probe_idle.py (idle_predicate.evaluate_takeover_reminder).
239
+ // Nodes / monitor_state passed as serde_json::Value dicts (Python dict shape).
240
+
@@ -0,0 +1,120 @@
1
+ fn line(v: serde_json::Value) -> String {
2
+ v.to_string()
3
+ }
4
+
5
+ // P0 — interrupt marker must match EXACTLY (provider_state/claude.py:75 `== _INTERRUPT_TEXT`),
6
+ // not `.contains()`. A transcript that merely QUOTES the marker must stay Unknown
7
+ // (ping-blocked); only the exact text is idle-eligible IdleInterrupted. §11 wrong-direction.
8
+ #[test]
9
+ fn p2_claude_interrupt_requires_exact_marker_text() {
10
+ let quote = classify(
11
+ Provider::ClaudeCode,
12
+ &line(serde_json::json!({"type":"user","uuid":"u1","message":{"content":[
13
+ {"type":"text","text":"prefix [Request interrupted by user] suffix"}]}})),
14
+ ProcessLiveness::Alive,
15
+ 0.0,
16
+ )
17
+ .unwrap();
18
+ assert_eq!(quote.state, TurnState::Unknown, "merely quoting the marker must NOT be idle-eligible");
19
+ assert_eq!(quote.reason, "no_turn_lifecycle_fact");
20
+
21
+ let exact = classify(
22
+ Provider::ClaudeCode,
23
+ &line(serde_json::json!({"type":"user","uuid":"u1","message":{"content":[
24
+ {"type":"text","text":"[Request interrupted by user]"}]}})),
25
+ ProcessLiveness::Alive,
26
+ 0.0,
27
+ )
28
+ .unwrap();
29
+ assert_eq!(exact.state, TurnState::IdleInterrupted);
30
+ assert_eq!(exact.turn_id.as_ref().map(TurnId::as_str), Some("u1"));
31
+ }
32
+
33
+ // P1 — claude api_error fault requires level=="error" (claude.py:54). Missing/other
34
+ // level → NO fault (Python count 0).
35
+ #[test]
36
+ fn p2_claude_api_error_fault_requires_level_error() {
37
+ let no_level = vec![serde_json::json!({"type":"system","subtype":"api_error","sessionId":"s-1"})];
38
+ assert!(read_fault_facts(&no_level, Provider::ClaudeCode).is_empty(), "api_error w/o level=error is not a fault");
39
+ let warn = vec![serde_json::json!({"type":"system","subtype":"api_error","level":"warning","sessionId":"s-1"})];
40
+ assert!(read_fault_facts(&warn, Provider::ClaudeCode).is_empty(), "level=warning is not a fault");
41
+
42
+ let err = vec![serde_json::json!({"type":"system","subtype":"api_error","level":"error","sessionId":"s-1"})];
43
+ let facts = read_fault_facts(&err, Provider::ClaudeCode);
44
+ assert_eq!(facts.len(), 1);
45
+ assert_eq!(facts[0].signature.as_str(), "api_error");
46
+ assert_eq!(facts[0].turn_id.as_ref().map(TurnId::as_str), Some("s-1"));
47
+ }
48
+
49
+ // P1 — claude api_error turn_id fallback chain = sessionId -> parentUuid -> uuid
50
+ // (claude.py:58), NOT sessionId -> requestId. Collapsing to None breaks C8 dedup.
51
+ #[test]
52
+ fn p2_claude_api_error_turn_id_fallback_parentuuid_then_uuid() {
53
+ let pu = vec![serde_json::json!({"type":"system","subtype":"api_error","level":"error","parentUuid":"pu-1"})];
54
+ let f = read_fault_facts(&pu, Provider::ClaudeCode);
55
+ assert_eq!(f.len(), 1);
56
+ assert_eq!(f[0].turn_id.as_ref().map(TurnId::as_str), Some("pu-1"), "parentUuid is in the chain (requestId is not)");
57
+
58
+ let uu = vec![serde_json::json!({"type":"system","subtype":"api_error","level":"error","uuid":"uu-1"})];
59
+ let f2 = read_fault_facts(&uu, Provider::ClaudeCode);
60
+ assert_eq!(f2.len(), 1);
61
+ assert_eq!(f2[0].turn_id.as_ref().map(TurnId::as_str), Some("uu-1"));
62
+ }
63
+
64
+ // P1 — codex requestApproval turn_id = params.turnId OR params.turn_id (codex.py:79).
65
+ #[test]
66
+ fn p2_codex_approval_turn_id_accepts_snake_case() {
67
+ let snake = vec![serde_json::json!({"jsonrpc":"2.0","method":"session/requestApproval","params":{"turn_id":"snake1"}})];
68
+ let f = read_fault_facts(&snake, Provider::Codex);
69
+ assert_eq!(f.len(), 1);
70
+ assert_eq!(f[0].turn_id.as_ref().map(TurnId::as_str), Some("snake1"), "snake-case turn_id must be honored");
71
+ }
72
+
73
+ // P1 — codex app-server turn/completed status completed/interrupted/inProgress map to
74
+ // idle/idle_interrupted/working (codex.py:69-74), not Unknown.
75
+ #[test]
76
+ fn p2_codex_app_server_status_completed_interrupted_in_progress() {
77
+ let app = |status: &str| {
78
+ line(serde_json::json!({"jsonrpc":"2.0","method":"turn/completed","params":{"turn":{"id":"t1","status":status}}}))
79
+ };
80
+ let c = classify(Provider::Codex, &app("completed"), ProcessLiveness::Alive, 0.0).unwrap();
81
+ assert_eq!((c.state, c.reason.as_str()), (TurnState::Idle, "completed"));
82
+ assert_eq!(c.turn_id.as_ref().map(TurnId::as_str), Some("t1"));
83
+
84
+ let i = classify(Provider::Codex, &app("interrupted"), ProcessLiveness::Alive, 0.0).unwrap();
85
+ assert_eq!((i.state, i.reason.as_str()), (TurnState::IdleInterrupted, "interrupted"));
86
+
87
+ let p = classify(Provider::Codex, &app("inProgress"), ProcessLiveness::Alive, 0.0).unwrap();
88
+ assert_eq!((p.state, p.reason.as_str()), (TurnState::Working, "open_turn"));
89
+ assert_eq!(p.turn_id.as_ref().map(TurnId::as_str), Some("t1"));
90
+ }
91
+
92
+ // P1 — codex event_msg task_started → open turn → working(alive) (codex.py:30-31).
93
+ #[test]
94
+ fn p2_codex_event_msg_task_started_is_open_turn() {
95
+ let txt = line(serde_json::json!({"type":"event_msg","payload":{"type":"task_started","turn_id":"ts1"}}));
96
+ let r = classify(Provider::Codex, &txt, ProcessLiveness::Alive, 0.0).unwrap();
97
+ assert_eq!((r.state, r.reason.as_str()), (TurnState::Working, "open_turn"));
98
+ assert_eq!(r.turn_id.as_ref().map(TurnId::as_str), Some("ts1"));
99
+ }
100
+
101
+ // SPAWN+FAKE-WORKER RED — Provider::Fake::build_command must invoke the fake-worker backing program
102
+ // (the single-binary `fake-worker` subcommand), NOT the bare placeholder vec!["fake"] (no backing
103
+ // binary). This is what makes launch(dry_run=false)'s spawn path exercisable with NO subscription
104
+ // provider. Golden intent: fake_worker.py + provider_cli/fake.py — a subscription-free backing worker.
105
+ #[test]
106
+ fn fake_build_command_invokes_fake_worker_not_bare_fake() {
107
+ let adapter = get_adapter(Provider::Fake);
108
+ let argv = adapter
109
+ .build_command(AuthMode::Subscription, None, None, None)
110
+ .expect("fake build_command");
111
+ assert_ne!(
112
+ argv,
113
+ vec!["fake".to_string()],
114
+ "Provider::Fake::build_command must not be the bare placeholder vec![\"fake\"] (no backing binary)"
115
+ );
116
+ assert!(
117
+ argv.iter().any(|a| a == "fake-worker"),
118
+ "Provider::Fake::build_command must invoke the `fake-worker` subcommand so the spawn path runs the fake backing worker; got {argv:?}"
119
+ );
120
+ }
@@ -0,0 +1,208 @@
1
+ fn node(id: &str, role: &str, state: &str) -> serde_json::Value {
2
+ serde_json::json!({"node_id": id, "role": role, "state": state})
3
+ }
4
+
5
+ #[test]
6
+ fn idle_any_non_idle_node_blocks_ping_with_node_reason() {
7
+ // probe: working → node_working, unknown → node_unknown,
8
+ // blocked_on_human → node_blocked_on_human, abnormal → node_abnormal,
9
+ // missing-state → node_unknown. should_ping=False every time.
10
+ // BLOOD-LINE: Unknown node blocks the ping via NoPingReason::Node(Unknown).
11
+ for (state, expect) in [
12
+ ("working", NoPingReason::Node(TurnState::Working)),
13
+ ("unknown", NoPingReason::Node(TurnState::Unknown)),
14
+ ("blocked_on_human", NoPingReason::Node(TurnState::BlockedOnHuman)),
15
+ ("abnormal", NoPingReason::Node(TurnState::Abnormal)),
16
+ ] {
17
+ let r = evaluate_takeover_reminder(&[node("w1", "worker", state)], None, 100.0, 60.0)
18
+ .expect("evaluate ok");
19
+ assert!(!r.should_ping, "{state} node must block ping");
20
+ assert_eq!(r.reason, expect, "{state} → {}", expect.reason_str());
21
+ }
22
+ // The exact `node_<state>` wire strings the Python `_result` emits.
23
+ assert_eq!(NoPingReason::Node(TurnState::Working).reason_str(), "node_working");
24
+ assert_eq!(NoPingReason::Node(TurnState::Unknown).reason_str(), "node_unknown");
25
+ assert_eq!(
26
+ NoPingReason::Node(TurnState::BlockedOnHuman).reason_str(),
27
+ "node_blocked_on_human"
28
+ );
29
+ assert_eq!(NoPingReason::Node(TurnState::Abnormal).reason_str(), "node_abnormal");
30
+ // missing-state node → node_unknown (state defaults to unknown, idle_predicate.py:49).
31
+ let missing = serde_json::json!({"node_id": "w1", "role": "worker"});
32
+ let r = evaluate_takeover_reminder(&[missing], None, 100.0, 60.0).expect("evaluate ok");
33
+ assert!(!r.should_ping);
34
+ assert_eq!(r.reason, NoPingReason::Node(TurnState::Unknown));
35
+ }
36
+
37
+ #[test]
38
+ fn idle_all_idle_but_not_armed_blocks() {
39
+ // probe all_idle_not_armed → should_ping=False, reason=not_armed_no_worker_turn.
40
+ // Worker idle alone never arms; only a DELEGATED state arms the watch (C1).
41
+ let r = evaluate_takeover_reminder(&[node("w1", "worker", "idle")], None, 100.0, 60.0)
42
+ .expect("evaluate ok");
43
+ assert!(!r.should_ping);
44
+ assert_eq!(r.reason, NoPingReason::NotArmedNoWorkerTurn);
45
+ }
46
+
47
+ #[test]
48
+ fn idle_armed_debounce_active_then_ping() {
49
+ // probe armed_debounce_active (all_idle_since=100, now=130, debounce=60,
50
+ // elapsed=30 < 60) → should_ping=False, reason=debounce_active.
51
+ let ms = serde_json::json!({
52
+ "opened_worker_turn_since_ack": true,
53
+ "all_idle_since": 100.0,
54
+ "pinged_for_episode": serde_json::Value::Null
55
+ });
56
+ let active = evaluate_takeover_reminder(&[node("w1", "worker", "idle")], Some(&ms), 130.0, 60.0)
57
+ .expect("evaluate ok");
58
+ assert!(!active.should_ping);
59
+ assert_eq!(active.reason, NoPingReason::DebounceActive);
60
+ // probe armed_debounce_elapsed (now=160, elapsed=60 >= 60) →
61
+ // should_ping=True, reason=all_idle_debounce_elapsed.
62
+ let elapsed = evaluate_takeover_reminder(&[node("w1", "worker", "idle")], Some(&ms), 160.0, 60.0)
63
+ .expect("evaluate ok");
64
+ assert!(elapsed.should_ping, "ping must fire at/after debounce");
65
+ assert_eq!(elapsed.reason, NoPingReason::AllIdleDebounceElapsed);
66
+ assert!(elapsed.message.is_some(), "ping carries the stored neutral message");
67
+ }
68
+
69
+ #[test]
70
+ fn idle_suppressed_is_acknowledged_and_already_pinged_guard() {
71
+ // probe armed_suppressed_acknowledged → should_ping=False, reason=acknowledged.
72
+ let supp = serde_json::json!({
73
+ "opened_worker_turn_since_ack": true,
74
+ "suppressed": true,
75
+ "all_idle_since": 100.0
76
+ });
77
+ let r = evaluate_takeover_reminder(&[node("w1", "worker", "idle")], Some(&supp), 200.0, 60.0)
78
+ .expect("evaluate ok");
79
+ assert!(!r.should_ping);
80
+ assert_eq!(r.reason, NoPingReason::Acknowledged);
81
+ // probe already_pinged_this_episode (pinged_for_episode == all_idle_since) →
82
+ // should_ping=False, reason=already_pinged_this_episode.
83
+ let pinged = serde_json::json!({
84
+ "opened_worker_turn_since_ack": true,
85
+ "all_idle_since": 100.0,
86
+ "pinged_for_episode": 100.0
87
+ });
88
+ let r2 = evaluate_takeover_reminder(&[node("w1", "worker", "idle")], Some(&pinged), 200.0, 60.0)
89
+ .expect("evaluate ok");
90
+ assert!(!r2.should_ping);
91
+ assert_eq!(r2.reason, NoPingReason::AlreadyPingedThisEpisode);
92
+ }
93
+
94
+ #[test]
95
+ fn idle_interrupted_counts_as_idle_and_appears_in_interrupted_nodes() {
96
+ // probe interrupted_counts_idle_ping → should_ping=True AND
97
+ // interrupted_nodes=["w1"] (C12: idle_interrupted is idle but annotated).
98
+ let armed = serde_json::json!({
99
+ "opened_worker_turn_since_ack": true,
100
+ "all_idle_since": 100.0,
101
+ "pinged_for_episode": serde_json::Value::Null
102
+ });
103
+ let r = evaluate_takeover_reminder(
104
+ &[node("w1", "worker", "idle_interrupted")],
105
+ Some(&armed),
106
+ 200.0,
107
+ 60.0,
108
+ )
109
+ .expect("evaluate ok");
110
+ assert!(r.should_ping);
111
+ assert_eq!(r.reason, NoPingReason::AllIdleDebounceElapsed);
112
+ assert_eq!(r.interrupted_nodes, vec!["w1".to_string()]);
113
+ }
114
+
115
+ #[test]
116
+ fn idle_leader_activity_never_arms_but_leader_idle_allows_ping() {
117
+ // probe leader_working_does_not_arm: leader-only working never arms; the
118
+ // working node still BLOCKS the ping → reason=node_working, should_ping=False.
119
+ let r = evaluate_takeover_reminder(&[node("leader", "leader", "working")], None, 100.0, 60.0)
120
+ .expect("evaluate ok");
121
+ assert!(!r.should_ping);
122
+ assert_eq!(r.reason, NoPingReason::Node(TurnState::Working));
123
+ // probe leader_and_worker_idle_armed_ping: once a WORKER opened a turn the
124
+ // watch is armed; leader+worker both idle past debounce → should_ping=True.
125
+ let armed = serde_json::json!({
126
+ "opened_worker_turn_since_ack": true,
127
+ "all_idle_since": 100.0,
128
+ "pinged_for_episode": serde_json::Value::Null
129
+ });
130
+ let nodes = [node("leader", "leader", "idle"), node("w1", "worker", "idle")];
131
+ let r2 = evaluate_takeover_reminder(&nodes, Some(&armed), 200.0, 60.0).expect("evaluate ok");
132
+ assert!(r2.should_ping);
133
+ assert_eq!(r2.reason, NoPingReason::AllIdleDebounceElapsed);
134
+ }
135
+
136
+ // ---- (c) trust-prompt recognizer (REAL fixtures, own-vs-foreign) ----
137
+ //
138
+ // NOTE: the own-vs-foreign trust recognizer lives in messaging/leader_panes.py
139
+ // (step 9/10 owns it per card §42). The provider.rs skeleton exposes only
140
+ // `status_patterns()` (idle/processing/trust regex set) — driven RED below.
141
+ // Full own-vs-foreign realpath judgement + truncated-workspace logic deferred.
142
+
143
+ // Real fixtures (mirrored into the rust workspace from team-agent-public).
144
+ const CLAUDE_IDLE_FIXTURE: &str = include_str!(concat!(
145
+ env!("CARGO_MANIFEST_DIR"),
146
+ "/../../snapshot/fixtures/idle_prompts/claude_code_idle.txt"
147
+ ));
148
+ const CODEX_IDLE_FIXTURE: &str = include_str!(concat!(
149
+ env!("CARGO_MANIFEST_DIR"),
150
+ "/../../snapshot/fixtures/idle_prompts/codex_idle.txt"
151
+ ));
152
+ const CODEX_WORKING_FIXTURE: &str = include_str!(concat!(
153
+ env!("CARGO_MANIFEST_DIR"),
154
+ "/../../snapshot/fixtures/idle_prompts/codex_working.txt"
155
+ ));
156
+
157
+ fn fixture_matches(re: &regex::Regex, fixture: &str) -> bool {
158
+ fixture.lines().any(|l| re.is_match(l))
159
+ }
160
+
161
+ #[test]
162
+ fn claude_status_patterns_compile() {
163
+ // provider_cli/claude.py:225 status_patterns idle=r"[>❯]\s".
164
+ // The real fixture has prompt lines like "❯ /compact" → idle MUST match.
165
+ // The processing pattern r"[✶✢✽✻✳·].*…" must NOT match those idle prompt lines.
166
+ let adapter = get_adapter(Provider::ClaudeCode);
167
+ let pats = adapter.status_patterns().expect("status_patterns ok");
168
+ assert!(
169
+ fixture_matches(&pats.idle, CLAUDE_IDLE_FIXTURE),
170
+ "claude idle pattern must match a '❯ ' prompt line in the idle fixture"
171
+ );
172
+ assert!(
173
+ pats.idle.is_match("❯ /compact"),
174
+ "claude idle pattern matches the canonical prompt line"
175
+ );
176
+ assert!(
177
+ !pats.processing.is_match("❯ /compact"),
178
+ "claude processing pattern must NOT match an idle prompt line"
179
+ );
180
+ }
181
+
182
+ #[test]
183
+ fn codex_status_patterns_compile() {
184
+ // provider_cli/codex.py:140 idle=r"(›|❯|codex>)" processing=r"•.*esc to interrupt".
185
+ // codex_idle.txt has a "› Find and fix a bug…" prompt → idle matches.
186
+ // codex_working.txt has a "• …esc to interrupt" spinner → processing matches.
187
+ let adapter = get_adapter(Provider::Codex);
188
+ let pats = adapter.status_patterns().expect("status_patterns ok");
189
+ assert!(
190
+ fixture_matches(&pats.idle, CODEX_IDLE_FIXTURE),
191
+ "codex idle pattern must match a '›' prompt line"
192
+ );
193
+ assert!(
194
+ CODEX_WORKING_FIXTURE.lines().any(|l| pats.processing.is_match(l)),
195
+ "codex processing pattern must match the 'esc to interrupt' spinner"
196
+ );
197
+ // The discriminator: a pure working-status footer line carries no prompt char.
198
+ assert!(
199
+ !pats.idle.is_match(" gpt-5.5 medium · /private/tmp/working"),
200
+ "codex idle pattern must NOT match a bare status footer"
201
+ );
202
+ }
203
+
204
+ // ---- (d) abnormal dedup key (signature, Option<TurnId>) ----
205
+ //
206
+ // NOTE: read_fault_facts lives in provider_state; not on the skeleton trait.
207
+ // Golden dedup keys recorded below; flagged deferred for the fault-facts entry.
208
+