failproofai 1.0.1 → 1.0.2-beta.0

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 (389) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +6 -6
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js +2 -2
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page.js +2 -2
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  21. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
  22. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/standalone/.next/server/app/api/audit/invite/route.js +3 -2
  24. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/audit/run/route.js +5 -4
  26. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/login-request/route.js +2 -2
  29. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +4 -3
  31. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/logout/route.js +4 -4
  33. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/status/route.js +4 -4
  35. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  38. package/.next/standalone/.next/server/app/audit/page.js +2 -2
  39. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/.next/server/app/index.html +1 -1
  42. package/.next/standalone/.next/server/app/index.rsc +15 -15
  43. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  44. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  45. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  46. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  47. package/.next/standalone/.next/server/app/page.js +2 -2
  48. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  49. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +66 -22
  51. package/.next/standalone/.next/server/app/policies/page.js +5 -4
  52. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  55. package/.next/standalone/.next/server/app/project/[name]/page.js +2 -2
  56. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  58. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  59. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  60. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +2 -2
  61. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  63. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  64. package/.next/standalone/.next/server/app/projects/page.js +2 -2
  65. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  68. package/.next/standalone/.next/server/app/settings/page.js +1 -1
  69. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  70. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  71. package/.next/standalone/.next/server/chunks/{[externals]__1qzqcio._.js → [externals]__0e32-7c._.js} +1 -1
  72. package/.next/standalone/.next/server/chunks/[externals]__1s462yl._.js +3 -0
  73. package/.next/standalone/.next/server/chunks/[root-of-the-server]__01eqny5._.js +3 -0
  74. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_0xu5z._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__1p8poxd._.js → [root-of-the-server]__0igxt32._.js} +2 -2
  76. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0neqlu-._.js +3 -0
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +3 -0
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0t9oqnn._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/_0bs-v8p._.js +3 -0
  80. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +3 -0
  81. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +3 -0
  82. package/.next/standalone/.next/server/chunks/_185gyk9._.js +3 -0
  83. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +5 -6
  84. package/.next/standalone/.next/server/chunks/lib_telemetry-id_ts_1k8gj9o._.js +3 -0
  85. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_09d5c79._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_04op_ou._.js} +1 -1
  86. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_1d2g73o._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_0dr20uz._.js} +1 -1
  87. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/src_hooks_0xbw3bu._.js +12 -0
  89. package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
  90. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +4 -0
  91. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +4 -0
  92. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +4 -0
  93. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0spkm68._.js → [root-of-the-server]__07gm3zl._.js} +21 -20
  94. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +4 -0
  95. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +4 -0
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +4 -0
  98. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +6 -0
  99. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__07tbbf3._.js → [root-of-the-server]__0zig5sv._.js} +2 -2
  100. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__192nhvh._.js +5 -0
  101. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +4 -0
  102. package/.next/standalone/.next/server/chunks/ssr/{_0lvyuv4._.js → _00e9tnf._.js} +2 -2
  103. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/{_0u5zauv._.js → _12l4a_k._.js} +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +13 -13
  108. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  110. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/src_hooks_08o7bqo._.js +10 -0
  112. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
  113. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
  114. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  115. package/.next/standalone/.next/server/middleware.js +1 -1
  116. package/.next/standalone/.next/server/pages/404.html +1 -1
  117. package/.next/standalone/.next/server/pages/500.html +1 -1
  118. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  119. package/.next/standalone/.next/server/server-reference-manifest.json +71 -27
  120. package/.next/standalone/.next/static/chunks/{37fcpc36x9gpe.js → 0eqky-7n7dgdm.js} +1 -1
  121. package/.next/standalone/.next/static/chunks/{1ide1wl2f7zi1.js → 0sa9v5mhc8861.js} +13 -13
  122. package/.next/standalone/.next/static/chunks/1mfzsja6ftim5.css +2 -0
  123. package/.next/standalone/.next/static/chunks/{3k7mxbp9hiom0.js → 1nuqytbta44af.js} +1 -1
  124. package/.next/standalone/.next/static/chunks/1sl2xmn9inx3y.js +6 -0
  125. package/.next/standalone/.next/static/chunks/{2_2lhuojq2xop.js → 2aawsjld9ied_.js} +1 -1
  126. package/.next/standalone/.next/static/chunks/2gn7ut4svofm2.js +1 -0
  127. package/.next/standalone/.next/static/chunks/{31s1smp-g7gks.js → 2hs7ycvsav7v2.js} +1 -1
  128. package/.next/standalone/.next/static/chunks/{0k5050ezjvv49.js → 2i2yt3hzjredm.js} +1 -1
  129. package/.next/standalone/.next/static/chunks/{3c808qrfcq0im.js → 35xg6retpe6rp.js} +1 -1
  130. package/.next/standalone/.next/static/chunks/{2zpp1we2jv6tb.js → 3y54nw0i7dsjl.js} +1 -1
  131. package/.next/standalone/SECURITY.md +5 -4
  132. package/.next/standalone/app/actions/get-hooks-config.ts +108 -17
  133. package/.next/standalone/app/actions/pack-actions.ts +153 -0
  134. package/.next/standalone/app/audit/_components/run-progress.tsx +1 -1
  135. package/.next/standalone/app/audit/_components/share-templates.ts +13 -13
  136. package/.next/standalone/app/policies/hooks-client.tsx +320 -14
  137. package/.next/standalone/components/navbar.tsx +2 -28
  138. package/.next/standalone/components/reach-developers.tsx +8 -1
  139. package/.next/standalone/docs-old/agenteye/python-sdk-skill.mdx +1 -1
  140. package/.next/standalone/docs-old/agenteye/python-sdk.mdx +59 -35
  141. package/.next/standalone/docs-old/i18n/README.ar.md +1 -0
  142. package/.next/standalone/docs-old/i18n/README.de.md +1 -0
  143. package/.next/standalone/docs-old/i18n/README.es.md +1 -0
  144. package/.next/standalone/docs-old/i18n/README.fr.md +1 -0
  145. package/.next/standalone/docs-old/i18n/README.he.md +1 -0
  146. package/.next/standalone/docs-old/i18n/README.hi.md +1 -0
  147. package/.next/standalone/docs-old/i18n/README.it.md +1 -0
  148. package/.next/standalone/docs-old/i18n/README.ja.md +1 -0
  149. package/.next/standalone/docs-old/i18n/README.ko.md +1 -0
  150. package/.next/standalone/docs-old/i18n/README.pt-br.md +1 -0
  151. package/.next/standalone/docs-old/i18n/README.ru.md +1 -0
  152. package/.next/standalone/docs-old/i18n/README.tr.md +1 -0
  153. package/.next/standalone/docs-old/i18n/README.vi.md +1 -0
  154. package/.next/standalone/docs-old/i18n/README.zh.md +1 -0
  155. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +431 -0
  156. package/.next/standalone/fp-cloud-cli/LICENSE +42 -0
  157. package/.next/standalone/fp-cloud-cli/MANIFEST.in +10 -0
  158. package/.next/standalone/fp-cloud-cli/README.md +259 -0
  159. package/.next/standalone/fp-cloud-cli/fp_cli/__init__.py +10 -0
  160. package/.next/standalone/fp-cloud-cli/fp_cli/__main__.py +4 -0
  161. package/.next/standalone/fp-cloud-cli/fp_cli/_click_compat.py +64 -0
  162. package/.next/standalone/fp-cloud-cli/fp_cli/_context.py +332 -0
  163. package/.next/standalone/fp-cloud-cli/fp_cli/_version.py +1 -0
  164. package/.next/standalone/fp-cloud-cli/fp_cli/analytics.py +432 -0
  165. package/.next/standalone/fp-cloud-cli/fp_cli/analytics_config.py +77 -0
  166. package/.next/standalone/fp-cloud-cli/fp_cli/analytics_registry.py +83 -0
  167. package/.next/standalone/fp-cloud-cli/fp_cli/app.py +492 -0
  168. package/.next/standalone/fp-cloud-cli/fp_cli/auth.py +160 -0
  169. package/.next/standalone/fp-cloud-cli/fp_cli/client.py +1694 -0
  170. package/.next/standalone/fp-cloud-cli/fp_cli/commands/__init__.py +0 -0
  171. package/.next/standalone/fp-cloud-cli/fp_cli/commands/_write.py +214 -0
  172. package/.next/standalone/fp-cloud-cli/fp_cli/commands/agent_cmds.py +407 -0
  173. package/.next/standalone/fp-cloud-cli/fp_cli/commands/alerts_cmds.py +445 -0
  174. package/.next/standalone/fp-cloud-cli/fp_cli/commands/audits_cmds.py +1054 -0
  175. package/.next/standalone/fp-cloud-cli/fp_cli/commands/auth_cmds.py +512 -0
  176. package/.next/standalone/fp-cloud-cli/fp_cli/commands/errors_cmds.py +190 -0
  177. package/.next/standalone/fp-cloud-cli/fp_cli/commands/evals_cmds.py +161 -0
  178. package/.next/standalone/fp-cloud-cli/fp_cli/commands/events_cmds.py +159 -0
  179. package/.next/standalone/fp-cloud-cli/fp_cli/commands/fleet_cmds.py +416 -0
  180. package/.next/standalone/fp-cloud-cli/fp_cli/commands/guardrails_cmds.py +148 -0
  181. package/.next/standalone/fp-cloud-cli/fp_cli/commands/incidents_cmds.py +472 -0
  182. package/.next/standalone/fp-cloud-cli/fp_cli/commands/keys_cmds.py +407 -0
  183. package/.next/standalone/fp-cloud-cli/fp_cli/commands/list_cmds.py +63 -0
  184. package/.next/standalone/fp-cloud-cli/fp_cli/commands/orgs_cmds.py +319 -0
  185. package/.next/standalone/fp-cloud-cli/fp_cli/commands/policies_cmds.py +499 -0
  186. package/.next/standalone/fp-cloud-cli/fp_cli/commands/queries_cmds.py +378 -0
  187. package/.next/standalone/fp-cloud-cli/fp_cli/commands/sessions_cmds.py +151 -0
  188. package/.next/standalone/fp-cloud-cli/fp_cli/commands/settings_cmds.py +150 -0
  189. package/.next/standalone/fp-cloud-cli/fp_cli/commands/usage_cmds.py +35 -0
  190. package/.next/standalone/fp-cloud-cli/fp_cli/commands/users_cmds.py +404 -0
  191. package/.next/standalone/fp-cloud-cli/fp_cli/config.py +330 -0
  192. package/.next/standalone/fp-cloud-cli/fp_cli/dates.py +78 -0
  193. package/.next/standalone/fp-cloud-cli/fp_cli/enforcement.py +345 -0
  194. package/.next/standalone/fp-cloud-cli/fp_cli/errors.py +98 -0
  195. package/.next/standalone/fp-cloud-cli/fp_cli/models.py +891 -0
  196. package/.next/standalone/fp-cloud-cli/fp_cli/orgs.py +30 -0
  197. package/.next/standalone/fp-cloud-cli/fp_cli/output.py +6593 -0
  198. package/.next/standalone/fp-cloud-cli/fp_cli/permissions.py +208 -0
  199. package/.next/standalone/fp-cloud-cli/fp_cli/policy_check.py +290 -0
  200. package/.next/standalone/fp-cloud-cli/fp_cli/py.typed +0 -0
  201. package/.next/standalone/fp-cloud-cli/fp_cli/select.py +322 -0
  202. package/.next/standalone/fp-cloud-cli/fp_cli/theme.py +53 -0
  203. package/.next/standalone/fp-cloud-cli/pyproject.toml +106 -0
  204. package/.next/standalone/fp-cloud-cli/skill/SKILL.md +302 -0
  205. package/.next/standalone/fp-cloud-cli/skill/agents/openai.yaml +8 -0
  206. package/.next/standalone/fp-cloud-cli/skill/references/commands.md +254 -0
  207. package/.next/standalone/fp-cloud-cli/tests/__init__.py +0 -0
  208. package/.next/standalone/fp-cloud-cli/tests/conftest.py +92 -0
  209. package/.next/standalone/fp-cloud-cli/tests/test_alerting.py +644 -0
  210. package/.next/standalone/fp-cloud-cli/tests/test_analytics.py +385 -0
  211. package/.next/standalone/fp-cloud-cli/tests/test_audits.py +950 -0
  212. package/.next/standalone/fp-cloud-cli/tests/test_auth.py +98 -0
  213. package/.next/standalone/fp-cloud-cli/tests/test_auth_mode.py +272 -0
  214. package/.next/standalone/fp-cloud-cli/tests/test_click_compat.py +118 -0
  215. package/.next/standalone/fp-cloud-cli/tests/test_client.py +299 -0
  216. package/.next/standalone/fp-cloud-cli/tests/test_commands.py +814 -0
  217. package/.next/standalone/fp-cloud-cli/tests/test_config.py +99 -0
  218. package/.next/standalone/fp-cloud-cli/tests/test_dashboards_agent.py +340 -0
  219. package/.next/standalone/fp-cloud-cli/tests/test_dates.py +76 -0
  220. package/.next/standalone/fp-cloud-cli/tests/test_enforcement_logic.py +481 -0
  221. package/.next/standalone/fp-cloud-cli/tests/test_facets.py +170 -0
  222. package/.next/standalone/fp-cloud-cli/tests/test_failproofai_home.py +722 -0
  223. package/.next/standalone/fp-cloud-cli/tests/test_fp_home_contract.py +198 -0
  224. package/.next/standalone/fp-cloud-cli/tests/test_hardening.py +186 -0
  225. package/.next/standalone/fp-cloud-cli/tests/test_help_table_coverage.py +127 -0
  226. package/.next/standalone/fp-cloud-cli/tests/test_keys_queries.py +496 -0
  227. package/.next/standalone/fp-cloud-cli/tests/test_list.py +59 -0
  228. package/.next/standalone/fp-cloud-cli/tests/test_multivalue.py +183 -0
  229. package/.next/standalone/fp-cloud-cli/tests/test_no_customer_identifiers.py +168 -0
  230. package/.next/standalone/fp-cloud-cli/tests/test_operator.py +492 -0
  231. package/.next/standalone/fp-cloud-cli/tests/test_orgs.py +692 -0
  232. package/.next/standalone/fp-cloud-cli/tests/test_output.py +1732 -0
  233. package/.next/standalone/fp-cloud-cli/tests/test_policy_check.py +191 -0
  234. package/.next/standalone/fp-cloud-cli/tests/test_readme_matches_reality.py +125 -0
  235. package/.next/standalone/fp-cloud-cli/tests/test_review_fixes.py +161 -0
  236. package/.next/standalone/fp-cloud-cli/tests/test_telemetry_completeness.py +90 -0
  237. package/.next/standalone/fp-cloud-cli/tests/test_usage.py +115 -0
  238. package/.next/standalone/fp-cloud-cli/tests/test_v1_origin_diagnostic.py +70 -0
  239. package/.next/standalone/fp-cloud-cli/tests/test_v1_routing.py +214 -0
  240. package/.next/standalone/fp-cloud-cli/tests/test_whoami.py +104 -0
  241. package/.next/standalone/fp-cloud-cli/uv.lock +552 -0
  242. package/.next/standalone/package.json +29 -22
  243. package/.next/standalone/sdk/python/CHANGELOG.md +70 -0
  244. package/.next/standalone/sdk/python/LICENSE +42 -0
  245. package/.next/standalone/sdk/python/MANIFEST.in +21 -0
  246. package/.next/standalone/sdk/python/README.md +466 -0
  247. package/.next/standalone/sdk/python/docs/README.md +249 -0
  248. package/.next/standalone/sdk/python/docs/_shared/README.md +43 -0
  249. package/.next/standalone/sdk/python/docs/_shared/__init__.py +195 -0
  250. package/.next/standalone/sdk/python/docs/crewai/README.md +328 -0
  251. package/.next/standalone/sdk/python/docs/crewai/examples/quickstart.py +61 -0
  252. package/.next/standalone/sdk/python/docs/crewai/examples/research_crew.py +92 -0
  253. package/.next/standalone/sdk/python/docs/langgraph/README.md +481 -0
  254. package/.next/standalone/sdk/python/docs/langgraph/examples/quickstart.py +51 -0
  255. package/.next/standalone/sdk/python/docs/langgraph/examples/research_agent.py +86 -0
  256. package/.next/standalone/sdk/python/docs/langgraph/examples/supervisor_handoff.py +83 -0
  257. package/.next/standalone/sdk/python/docs/llama_index/README.md +332 -0
  258. package/.next/standalone/sdk/python/docs/llama_index/examples/quickstart.py +62 -0
  259. package/.next/standalone/sdk/python/docs/llama_index/examples/research_agent.py +93 -0
  260. package/.next/standalone/sdk/python/docs/manual/README.md +491 -0
  261. package/.next/standalone/sdk/python/docs/manual/examples/quickstart.py +57 -0
  262. package/.next/standalone/sdk/python/docs/manual/examples/research_agent.py +133 -0
  263. package/.next/standalone/sdk/python/docs/pydantic_ai/README.md +323 -0
  264. package/.next/standalone/sdk/python/docs/pydantic_ai/examples/quickstart.py +48 -0
  265. package/.next/standalone/sdk/python/docs/pydantic_ai/examples/research_agent.py +94 -0
  266. package/.next/standalone/sdk/python/failproofai_sdk/__init__.py +131 -0
  267. package/.next/standalone/sdk/python/failproofai_sdk/_context.py +218 -0
  268. package/.next/standalone/sdk/python/failproofai_sdk/_environment.py +78 -0
  269. package/.next/standalone/sdk/python/failproofai_sdk/_events.py +752 -0
  270. package/.next/standalone/sdk/python/failproofai_sdk/_resolver.py +118 -0
  271. package/.next/standalone/sdk/python/failproofai_sdk/_runtime.py +21 -0
  272. package/.next/standalone/sdk/python/failproofai_sdk/_schema.py +317 -0
  273. package/.next/standalone/sdk/python/failproofai_sdk/_scopes.py +422 -0
  274. package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -0
  275. package/.next/standalone/sdk/python/failproofai_sdk/_writer.py +750 -0
  276. package/.next/standalone/sdk/python/failproofai_sdk/integrations/__init__.py +274 -0
  277. package/.next/standalone/sdk/python/failproofai_sdk/integrations/_compat.py +247 -0
  278. package/.next/standalone/sdk/python/failproofai_sdk/integrations/_core.py +1076 -0
  279. package/.next/standalone/sdk/python/failproofai_sdk/integrations/crewai.py +1669 -0
  280. package/.next/standalone/sdk/python/failproofai_sdk/integrations/langchain.py +2151 -0
  281. package/.next/standalone/sdk/python/failproofai_sdk/integrations/llama_index.py +1588 -0
  282. package/.next/standalone/sdk/python/failproofai_sdk/integrations/pydantic_ai.py +977 -0
  283. package/.next/standalone/sdk/python/failproofai_sdk/py.typed +0 -0
  284. package/.next/standalone/sdk/python/pyproject.toml +92 -0
  285. package/.next/standalone/sdk/python/skill/SKILL.md +399 -0
  286. package/.next/standalone/sdk/python/skill/agents/openai.yaml +8 -0
  287. package/.next/standalone/sdk/python/skill/references/events.md +234 -0
  288. package/.next/standalone/sdk/python/skill/references/frameworks.md +314 -0
  289. package/.next/standalone/sdk/python/skill/references/install.md +100 -0
  290. package/.next/standalone/sdk/python/skill/references/integration.md +352 -0
  291. package/.next/standalone/sdk/python/tests/__init__.py +0 -0
  292. package/.next/standalone/sdk/python/tests/conftest.py +169 -0
  293. package/.next/standalone/sdk/python/tests/integrations/__init__.py +6 -0
  294. package/.next/standalone/sdk/python/tests/integrations/test_crewai.py +1299 -0
  295. package/.next/standalone/sdk/python/tests/integrations/test_langchain.py +2338 -0
  296. package/.next/standalone/sdk/python/tests/integrations/test_llama_index.py +1576 -0
  297. package/.next/standalone/sdk/python/tests/integrations/test_pydantic_ai.py +1102 -0
  298. package/.next/standalone/sdk/python/tests/test_context.py +410 -0
  299. package/.next/standalone/sdk/python/tests/test_docs.py +411 -0
  300. package/.next/standalone/sdk/python/tests/test_durability.py +1352 -0
  301. package/.next/standalone/sdk/python/tests/test_encoding.py +571 -0
  302. package/.next/standalone/sdk/python/tests/test_integrations.py +1127 -0
  303. package/.next/standalone/sdk/python/tests/test_no_customer_identifiers.py +214 -0
  304. package/.next/standalone/sdk/python/tests/test_packaging.py +185 -0
  305. package/.next/standalone/sdk/python/tests/test_resolver_umbrella.py +178 -0
  306. package/.next/standalone/sdk/python/tests/test_scopes.py +487 -0
  307. package/.next/standalone/sdk/python/tests/test_sdk.py +923 -0
  308. package/.next/standalone/sdk/python/tests/test_server_contract.py +650 -0
  309. package/.next/standalone/sdk/python/tests/test_site_docs.py +371 -0
  310. package/.next/standalone/sdk/python/tests/test_skill_snippets.py +165 -0
  311. package/.next/standalone/sdk/python/tests/test_spool_contract.py +287 -0
  312. package/.next/standalone/sdk/python/tests/test_spool_creation.py +360 -0
  313. package/.next/standalone/sdk/python/tests/test_wire_format.py +279 -0
  314. package/.next/standalone/sdk/python/tests/test_zero_dependencies.py +378 -0
  315. package/.next/standalone/sdk/python/uv.lock +6005 -0
  316. package/.next/standalone/server.js +1 -1
  317. package/README.md +77 -21
  318. package/bin/failproofai.mjs +145 -11
  319. package/dist/cli.mjs +4482 -2205
  320. package/dist/index.js +5 -5
  321. package/dist/worker.mjs +2399 -1978
  322. package/package.json +29 -22
  323. package/policy-pack/SHA256SUMS +2 -0
  324. package/policy-pack/failproofai-pack.json +732 -0
  325. package/policy-pack/failproofai-pack.mjs +2017 -0
  326. package/scripts/build-policy-pack.mjs +121 -0
  327. package/scripts/changelog-section.py +77 -0
  328. package/scripts/prune-standalone.mjs +3 -0
  329. package/scripts/python-version.py +232 -0
  330. package/scripts/translate-docs/cli.ts +21 -0
  331. package/scripts/translate-docs/mintlify-nav.ts +85 -13
  332. package/scripts/translate-docs/translator.ts +66 -11
  333. package/src/audit/cache.ts +40 -3
  334. package/src/audit/cli.ts +1 -1
  335. package/src/audit/index.ts +1 -1
  336. package/src/audit/replay.ts +89 -3
  337. package/src/audit/report.ts +5 -5
  338. package/src/audit/schedule-cli.ts +49 -53
  339. package/src/hooks/builtin-policies.ts +217 -579
  340. package/src/hooks/cloud-enrollment-cli.ts +66 -27
  341. package/src/hooks/cloud-managed-policies.ts +6 -1
  342. package/src/hooks/configure-wizard.ts +1 -1
  343. package/src/hooks/custom-hooks-loader.ts +89 -6
  344. package/src/hooks/fp-home.ts +92 -4
  345. package/src/hooks/fp-reset.ts +26 -2
  346. package/src/hooks/handler.ts +181 -11
  347. package/src/hooks/harness-cli.ts +79 -25
  348. package/src/hooks/hook-activity-store.ts +13 -3
  349. package/src/hooks/install-prompt.ts +25 -3
  350. package/src/hooks/manager.ts +418 -172
  351. package/src/hooks/migrations.ts +2 -2
  352. package/src/hooks/pack-cli.ts +505 -0
  353. package/src/hooks/pack-failclosed.ts +189 -0
  354. package/src/hooks/pack-manifest.ts +344 -0
  355. package/src/hooks/pack-store.ts +930 -0
  356. package/src/hooks/policy-catalog.ts +535 -0
  357. package/src/hooks/policy-evaluator.ts +24 -16
  358. package/src/hooks/policy-presets.ts +4 -2
  359. package/src/hooks/policy-registry.ts +8 -2
  360. package/src/hooks/policy-types.ts +26 -0
  361. package/src/hooks/session-pause-cli.ts +33 -9
  362. package/src/hooks/tui.ts +570 -0
  363. package/src/hooks/types.ts +15 -1
  364. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_usx0j._.js +0 -3
  365. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_zk340._.js +0 -3
  366. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0agdw2a._.js +0 -3
  367. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0d1u_na._.js +0 -3
  368. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0oy4kxb._.js +0 -3
  369. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zf034w._.js +0 -3
  370. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tvn4wf._.js +0 -3
  371. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +0 -3
  372. package/.next/standalone/.next/server/chunks/_1ger0a2._.js +0 -3
  373. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +0 -3
  374. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_1iiwm5e._.js +0 -3
  375. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kc8_v5._.js +0 -4
  376. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kgy9da._.js +0 -4
  377. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mhqk5a._.js +0 -4
  378. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pltpn8._.js +0 -4
  379. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qkcyf-._.js +0 -4
  380. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0w0x_ok._.js +0 -4
  381. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1_dfocn._.js +0 -4
  382. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1bfex62._.js +0 -5
  383. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1kzapg5._.js +0 -6
  384. package/.next/standalone/.next/static/chunks/36fw6yk1s6x_f.js +0 -1
  385. package/.next/standalone/.next/static/chunks/3tzq0260n17ae.js +0 -6
  386. package/.next/standalone/.next/static/chunks/3wq9x0c-7ld5d.css +0 -2
  387. /package/.next/standalone/.next/static/{ZklXxyA5ygsWSQz2u_zii → nwbyWT06Ud0XdXOrVJyvS}/_buildManifest.js +0 -0
  388. /package/.next/standalone/.next/static/{ZklXxyA5ygsWSQz2u_zii → nwbyWT06Ud0XdXOrVJyvS}/_clientMiddlewareManifest.js +0 -0
  389. /package/.next/standalone/.next/static/{ZklXxyA5ygsWSQz2u_zii → nwbyWT06Ud0XdXOrVJyvS}/_ssgManifest.js +0 -0
@@ -0,0 +1,1732 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from rich.console import Console
6
+
7
+ from fp_cli import output, theme
8
+ from fp_cli.models import (Alert, AgentEvent, ApiKey, DashboardUser, Evaluation, Incident,
9
+ IncidentComment, IncidentSubscriber, QueryResult, SavedQuery, Session,
10
+ SessionUser)
11
+
12
+
13
+ def _event(ts: str, event_type: str = "tool_use") -> AgentEvent:
14
+ return AgentEvent(id=1, session_id="sess-1", agent_id="bot",
15
+ event_type=event_type, ts=ts, environment="prod")
16
+
17
+
18
+ def _wide_stdout(width: int = 140) -> None:
19
+ """Force wide, no-color stdout+stderr consoles so panels/footers never wrap in assertions."""
20
+ output.configure(no_color=True, quiet=False)
21
+ output._stdout = Console(width=width, no_color=True)
22
+ output._stderr = Console(stderr=True, width=width, no_color=True)
23
+
24
+
25
+ def test_format_scores():
26
+ assert output.format_scores(None) == "-"
27
+ assert output.format_scores({}) == "-"
28
+ rendered = output.format_scores({"helpfulness": 0.857, "count": 1})
29
+ assert "helpfulness=0.86" in rendered
30
+ assert "count=1.00" in rendered
31
+
32
+
33
+ def test_emit_json_roundtrip(capsys):
34
+ output.emit_json({"a": 1, "b": [1, 2, 3]})
35
+ out = capsys.readouterr().out
36
+ assert json.loads(out) == {"a": 1, "b": [1, 2, 3]}
37
+
38
+
39
+ def test_emit_json_serializes_dataclasses(capsys):
40
+ output.emit_json({"user": SessionUser(id="u1", email="e@test")})
41
+ data = json.loads(capsys.readouterr().out)
42
+ assert data["user"] == {
43
+ "id": "u1",
44
+ "email": "e@test",
45
+ "is_instance_admin": False,
46
+ "memberships": [],
47
+ }
48
+
49
+
50
+ def test_print_table_writes_to_stdout(capsys):
51
+ output.configure(no_color=True, quiet=False)
52
+ output.print_table(["a", "b"], [["1", "2"]], title="Demo")
53
+ out = capsys.readouterr().out
54
+ assert "Demo" in out
55
+ assert "1" in out and "2" in out
56
+
57
+
58
+ def test_info_and_error_go_to_stderr(capsys):
59
+ output.configure(no_color=True, quiet=False)
60
+ output.info("hello")
61
+ output.error("boom")
62
+ captured = capsys.readouterr()
63
+ assert "hello" in captured.err
64
+ assert "boom" in captured.err
65
+ assert captured.out == ""
66
+
67
+
68
+ def test_quiet_suppresses_info(capsys):
69
+ output.configure(no_color=True, quiet=True)
70
+ output.info("hidden")
71
+ output.error("shown")
72
+ captured = capsys.readouterr()
73
+ assert "hidden" not in captured.err
74
+ assert "shown" in captured.err
75
+ # reset for other tests
76
+ output.configure(no_color=True, quiet=False)
77
+
78
+
79
+ # --- events box renderer ----------------------------------------------------
80
+
81
+
82
+ def test_parse_iso():
83
+ assert output._parse_iso("2026-06-22T12:21:54.104714Z") is not None
84
+ assert output._parse_iso("2026-06-22T12:21:54+00:00") is not None
85
+ assert output._parse_iso("t") is None # opaque/unparseable → None (caller falls back)
86
+ assert output._parse_iso("") is None
87
+
88
+
89
+ def test_render_events_box_to_stdout(capsys):
90
+ output.configure(no_color=True, quiet=False)
91
+ output.render_events([_event("2026-06-22T12:21:54Z")], order="desc")
92
+ captured = capsys.readouterr()
93
+ # The box (human data view) goes to STDOUT; the title carries count, direction and date.
94
+ assert "events" in captured.out
95
+ assert "newest first" in captured.out
96
+ assert "2026-06-22" in captured.out
97
+ assert captured.err == ""
98
+
99
+
100
+ def test_render_events_order_and_empty(capsys):
101
+ output.configure(no_color=True, quiet=False)
102
+ output.render_events([], order="asc")
103
+ out = capsys.readouterr().out
104
+ assert "oldest first" in out
105
+ assert "no events in this window" in out
106
+
107
+
108
+ def test_events_footer_more_available(capsys):
109
+ output.configure(no_color=True, quiet=False)
110
+ output.events_footer(10, more=True)
111
+ err = capsys.readouterr().err # the footer hint is stderr chrome
112
+ assert "10 shown" in err
113
+ assert "more available" in err
114
+ assert "fp events --all" in err
115
+
116
+
117
+ def test_events_footer_no_more_and_quiet(capsys):
118
+ output.configure(no_color=True, quiet=False)
119
+ output.events_footer(3, more=False)
120
+ err = capsys.readouterr().err
121
+ assert "3 shown" in err
122
+ assert "more available" not in err
123
+ output.configure(no_color=True, quiet=True)
124
+ output.events_footer(3, more=True)
125
+ assert capsys.readouterr().err == "" # suppressed under --quiet
126
+ output.configure(no_color=True, quiet=False)
127
+
128
+
129
+ def test_events_columns_reordered(capsys):
130
+ # New column order: time, type, env, agent, session.
131
+ _wide_stdout()
132
+ output.render_events([_event("2026-06-22T12:21:54Z", "tool_use")], order="desc")
133
+ out = capsys.readouterr().out
134
+ i = {c: out.index(c) for c in ("time", "type", "env", "agent", "session")}
135
+ assert i["time"] < i["type"] < i["env"] < i["agent"] < i["session"]
136
+ output.configure(no_color=True, quiet=False)
137
+
138
+
139
+ # --- sessions box renderer + score helpers ----------------------------------
140
+
141
+
142
+ def test_fmt_score_num():
143
+ assert output._fmt_score_num(0.94) == ".94"
144
+ assert output._fmt_score_num(0.90) == ".90"
145
+ assert output._fmt_score_num(1.0) == "1.0"
146
+ assert output._fmt_score_num(0.0) == ".00"
147
+ assert output._fmt_score_num(0.7) == ".70"
148
+
149
+
150
+ def test_fmt_avg():
151
+ # aggregate avg keeps the leading zero; 1.00 -> 1.0
152
+ assert output._fmt_avg(0.71) == "0.71"
153
+ assert output._fmt_avg(0.99) == "0.99"
154
+ assert output._fmt_avg(1.0) == "1.0"
155
+
156
+
157
+ def test_score_value_thresholds():
158
+ output.configure(no_color=False, quiet=False) # no '!' marker in the colour path
159
+ assert output._score_value(0.94) == (".94", theme.SCORE_HIGH) # >= .80 cyan-green
160
+ assert output._score_value(0.66) == (".66", theme.AMBER) # .50–.80 amber
161
+ assert output._score_value(0.45) == (".45", theme.ERROR) # < .50 red
162
+ # non-numeric: pass/fail substring rule
163
+ assert output._score_value("fail") == ("fail", theme.ERROR)
164
+ assert output._score_value("pass") == ("pass", theme.SUCCESS)
165
+ assert output._score_value("partial")[1] == theme.TEXT_DIM
166
+
167
+
168
+ def test_score_value_no_color_failure_marker():
169
+ output.configure(no_color=True, quiet=False)
170
+ label, _ = output._score_value(0.45)
171
+ assert label == ".45!" # < .50 gets a '!' so failures stay visible without colour
172
+ label_ok, _ = output._score_value(0.66)
173
+ assert label_ok == ".66" # >= .50 gets no marker
174
+ output.configure(no_color=False, quiet=False)
175
+
176
+
177
+ def test_status_color_enum():
178
+ assert output._status_cell("done").style == theme.SUCCESS
179
+ assert output._status_cell("error").style == theme.ERROR
180
+ assert output._status_cell("timeout").style == theme.ERROR
181
+ assert output._status_cell("weird-unknown").style == theme.TEXT_DIM # never crash
182
+
183
+
184
+ def test_short_session():
185
+ assert output._short_session("sess-20260615-fcf97e01") == "sess-…fcf97e01"
186
+ assert output._short_session("sess-20260615-fcf97e01", full=True) == "sess-20260615-fcf97e01"
187
+ assert output._short_session("nonnum-3-b9d9de") == "nonnum-3-b9d9de" # short → intact
188
+ assert output._short_session("short") == "short"
189
+
190
+
191
+ def test_scores_cell_budget_truncates_with_plus_n():
192
+ scores = {"a": 0.9, "b": 0.8, "c": 0.7, "d": 0.6, "e": 0.5}
193
+ cell = output._scores_cell(scores, budget=24)
194
+ assert "+" in cell.plain # leftover pairs collapsed to +N
195
+ assert cell.cell_len <= 24 + 1 # stays within (roughly) the budget
196
+ full = output._scores_cell(scores, full=True)
197
+ assert "+" not in full.plain # full shows every pair
198
+ assert "a" in full.plain and "e" in full.plain
199
+
200
+
201
+ def _sample_eval():
202
+ return Evaluation(id="1", session_id="sess-20260615-fcf97e01", agent_id="agent-codegen",
203
+ environment="staging", status="done", scores={"coherence": 0.94},
204
+ completed_at="2026-06-22T12:56:31Z")
205
+
206
+
207
+ def test_render_sessions_box_has_no_scores(capsys):
208
+ _wide_stdout()
209
+ output.render_sessions([_sample_eval()])
210
+ out = capsys.readouterr().out
211
+ assert "sessions" in out and "newest first" in out and "2026-06-22" in out
212
+ assert "agent-codegen" in out and "done" in out
213
+ assert "12:56:31" in out and "sess-…fcf97e01" in out
214
+ assert "coherence" not in out and "scores" not in out # scores moved to evals
215
+ output.configure(no_color=True, quiet=False)
216
+
217
+
218
+ def test_render_evals_box_has_scores(capsys):
219
+ _wide_stdout()
220
+ output.render_evals([_sample_eval()])
221
+ out = capsys.readouterr().out
222
+ assert "evals" in out and "scores" in out and "coherence" in out
223
+ assert "agent-codegen" in out and "sess-…fcf97e01" in out
224
+ output.configure(no_color=True, quiet=False)
225
+
226
+
227
+ def test_render_sessions_empty(capsys):
228
+ _wide_stdout()
229
+ output.render_sessions([])
230
+ assert "no sessions" in capsys.readouterr().out
231
+ output.configure(no_color=True, quiet=False)
232
+
233
+
234
+ def test_sessions_footer_plain_no_legend(capsys):
235
+ output.configure(no_color=True, quiet=False)
236
+ output.sessions_footer(3, more=True)
237
+ err = capsys.readouterr().err
238
+ assert "3 shown" in err and "fp sessions --all" in err
239
+
240
+
241
+ # ── multi-agent roster (agents column) ─────────────────────────────────────
242
+
243
+ def _multi_session():
244
+ """A 3-agent session: root `agent-codegen`, roster sorted by event count desc."""
245
+ return Session(
246
+ session_id="sess-20260716-abcd1234", agent_id="agent-codegen", environment="dev",
247
+ last_event_at="2026-07-16T14:00:00Z",
248
+ agents=[
249
+ {"agent_id": "agent-codegen", "event_count": 52},
250
+ {"agent_id": "agent-linter", "event_count": 18},
251
+ {"agent_id": "agent-testgen", "event_count": 9},
252
+ ],
253
+ )
254
+
255
+
256
+ def _single_session():
257
+ return Session(
258
+ session_id="sess-20260716-single01", agent_id="solo-agent", environment="dev",
259
+ last_event_at="2026-07-16T13:00:00Z",
260
+ agents=[{"agent_id": "solo-agent", "event_count": 12}],
261
+ )
262
+
263
+
264
+ def test_is_multi_agent():
265
+ assert output.is_multi_agent(_multi_session()) is True
266
+ assert output.is_multi_agent(_single_session()) is False
267
+ # a legacy row with no `agents` field is never multi-agent (back-compat)
268
+ assert output.is_multi_agent(Session(session_id="s", agent_id="a", environment="dev")) is False
269
+
270
+
271
+ def test_render_sessions_shows_plus_n_badge(capsys):
272
+ _wide_stdout()
273
+ output.render_sessions([_multi_session()])
274
+ out = capsys.readouterr().out
275
+ assert "agent-codegen +2" in out # 3 agents → +2 others, badge next to the root
276
+ # the other agents' NAMES are never listed inline in the default view
277
+ assert "agent-linter" not in out and "agent-testgen" not in out
278
+ output.configure(no_color=True, quiet=False)
279
+
280
+
281
+ def test_render_sessions_single_agent_has_no_badge(capsys):
282
+ _wide_stdout()
283
+ output.render_sessions([_single_session()])
284
+ out = capsys.readouterr().out
285
+ assert "solo-agent" in out
286
+ assert "+" not in out # single-agent → no badge at all
287
+ output.configure(no_color=True, quiet=False)
288
+
289
+
290
+ def test_render_sessions_expanded_lists_full_roster(capsys):
291
+ _wide_stdout()
292
+ output.render_sessions_expanded([_multi_session()])
293
+ out = capsys.readouterr().out
294
+ assert "agent-codegen" in out and "52 ev" in out # every agent listed with its count
295
+ assert "agent-linter" in out and "18 ev" in out
296
+ assert "agent-testgen" in out and "9 ev" in out
297
+ assert "●" not in out # uniform: no special "root" marker
298
+ assert "├" in out and "└" in out # uniform tree glyphs, └ closes the list
299
+ assert "sessions · 1" in out # panel count = sessions, not rows
300
+ output.configure(no_color=True, quiet=False)
301
+
302
+
303
+ def test_render_sessions_expanded_skips_single_agent(capsys):
304
+ _wide_stdout()
305
+ output.render_sessions_expanded([_single_session()])
306
+ out = capsys.readouterr().out
307
+ assert "solo-agent" in out
308
+ assert "├ solo-agent" not in out # single-agent rows are not expanded
309
+ output.configure(no_color=True, quiet=False)
310
+
311
+
312
+ def test_sessions_footer_multi_agent(capsys):
313
+ output.configure(no_color=True, quiet=False)
314
+ output.sessions_footer(6, more=True, multi_agent=2)
315
+ err = capsys.readouterr().err
316
+ assert "6 shown" in err
317
+ assert "2 multi-agent" in err and "fp sessions --agents" in err
318
+ assert "fp sessions --all" in err # the more-available segment still shows
319
+
320
+
321
+ def test_sessions_footer_no_multi_agent_hides_segment(capsys):
322
+ output.configure(no_color=True, quiet=False)
323
+ output.sessions_footer(3, more=False, multi_agent=0)
324
+ err = capsys.readouterr().err
325
+ assert "3 shown" in err
326
+ assert "multi-agent" not in err and "--agents" not in err
327
+ assert "score:" not in err # sessions has no scores → no legend
328
+ output.configure(no_color=True, quiet=False)
329
+
330
+
331
+ def test_evals_footer_has_legend(capsys):
332
+ output.configure(no_color=True, quiet=False)
333
+ output.evals_footer(3, more=False)
334
+ err = capsys.readouterr().err
335
+ assert "3 shown" in err
336
+ assert "score:" in err and "≥.80" in err and "<.50" in err
337
+ output.configure(no_color=True, quiet=False)
338
+
339
+
340
+ def test_avg_bar_zoomed_braille():
341
+ output.configure(no_color=False, quiet=False) # colour path → braille glyphs
342
+ # zoomed .40–1.0: 0.70 → (0.30/0.60)=0.5 → 5 filled
343
+ bar = output._avg_bar(0.70)
344
+ assert bar.plain.count("⣿") == 5 and bar.plain.count("⣀") == 5
345
+ assert output._avg_bar(1.0).plain.count("⣿") == 10 # at/above hi → full
346
+ assert output._avg_bar(0.40).plain.count("⣿") == 0 # at floor → empty
347
+ assert output._avg_bar(None).plain == "⣀" * 10 # no avg → all track
348
+
349
+
350
+ def test_avg_bar_blocks_when_no_color():
351
+ output.configure(no_color=True, quiet=False) # no-colour → solid-block fallback
352
+ bar = output._avg_bar(0.70)
353
+ assert bar.plain.count("█") == 5 and bar.plain.count("░") == 5
354
+ output.configure(no_color=False, quiet=False)
355
+
356
+
357
+ def test_render_eval_aggregate(capsys):
358
+ _wide_stdout()
359
+ data = {
360
+ "total": 324,
361
+ "status_counts": {"done": 320, "error": 4, "timeout": 0},
362
+ "score_stats": [
363
+ {"key": "helpfulness", "count": 285, "avg": 0.66, "min": 0.28, "max": 1.0, "p50": 0.7},
364
+ {"key": "coherence", "count": 13, "avg": 0.81, "min": 0.54, "max": 0.98, "p50": 0.83},
365
+ ],
366
+ "timeline": {"bucket_unit": "day", "points": []},
367
+ }
368
+ output.render_eval_aggregate(data)
369
+ cap = capsys.readouterr()
370
+ out = cap.out
371
+ assert "eval-aggregate" in out and "324" in out and "320 done" in out
372
+ assert "98.8% success rate" in out # 320/324
373
+ assert "score stats" in out and "2 metrics" in out and "sorted by avg" in out
374
+ # worst-avg first: helpfulness (.66) before coherence (.81)
375
+ assert out.index("helpfulness") < out.index("coherence")
376
+ assert "0.66" in out and "0.81" in out # avg keeps leading zero
377
+ # the band/scale legend prints under the panel (stderr chrome)
378
+ assert "scale .40–1.0" in cap.err and "≥.80" in cap.err
379
+ output.configure(no_color=True, quiet=False)
380
+
381
+
382
+ # --- errors list + aggregate -------------------------------------------------
383
+
384
+
385
+ def _errevent(event_type, summary, ts="2026-06-22T17:51:34Z"):
386
+ # A light-feed (/events/summary) row: the server supplies `summary` precomputed; the CLI
387
+ # renders it directly and never parses the (absent) payload.
388
+ return AgentEvent(id=1, session_id="sess-20260622-4b90b240", agent_id="agent-orderbot",
389
+ event_type=event_type, ts=ts, environment="prod", summary=summary,
390
+ is_error=("error" in event_type or "fail" in event_type))
391
+
392
+
393
+ def test_event_cell_red_only_on_error_substring():
394
+ output.configure(no_color=False, quiet=False)
395
+ err = output._event_cell("error")
396
+ ok = output._event_cell("tool_result")
397
+ # the error type's spans are ERROR red; a neutral type has none
398
+ assert any(s.style == theme.ERROR for s in err.spans)
399
+ assert not any(s.style == theme.ERROR for s in ok.spans)
400
+
401
+
402
+ def test_render_errors_box(capsys):
403
+ _wide_stdout()
404
+ output.render_errors([_errevent("error", "RateLimitError: upstream timed out")])
405
+ out = capsys.readouterr().out
406
+ assert "errors" in out and "newest first" in out
407
+ assert "agent-orderbot" in out and "sess-…4b90b240" in out # truncated session (shared last-8)
408
+ assert "RateLimitError: upstream timed out" in out # server-computed summary field
409
+ assert "17:51:34" in out
410
+ output.configure(no_color=True, quiet=False)
411
+
412
+
413
+ def test_render_errors_empty(capsys):
414
+ _wide_stdout()
415
+ output.render_errors([])
416
+ assert "no errors" in capsys.readouterr().out
417
+ output.configure(no_color=True, quiet=False)
418
+
419
+
420
+ def test_render_error_aggregate_card(capsys):
421
+ _wide_stdout()
422
+ output.render_error_aggregate({"total": 66, "sessions": 62, "agents": 6, "last_ts": "2026-01-01T00:00:00Z", "bins": []})
423
+ out = capsys.readouterr().out
424
+ assert "errors-aggregate" in out and "66" in out and "errored events" in out
425
+ assert "across" in out and "62 sessions" in out and "6 agents" in out
426
+ output.configure(no_color=True, quiet=False)
427
+
428
+
429
+ def test_render_error_aggregate_empty_is_healthy(capsys):
430
+ _wide_stdout()
431
+ output.render_error_aggregate({"total": 0, "sessions": 0, "agents": 0, "last_ts": None, "bins": []})
432
+ out = capsys.readouterr().out
433
+ assert "no errors found" in out
434
+ output.configure(no_color=True, quiet=False)
435
+
436
+
437
+ def test_relative_age():
438
+ assert output._relative_age(None) == ""
439
+ assert output._relative_age("not-a-ts") == ""
440
+ assert output._relative_age("2020-01-01T00:00:00Z").endswith("ago") # well in the past
441
+
442
+
443
+ # --- orgs: current card + perms + shared panels -----------------------------
444
+
445
+ _ORGS = [
446
+ {"is_active": True, "slug": "globex", "name": "Globex Corp", "role": "admin", "perms": 28},
447
+ {"is_active": False, "slug": "acme", "name": "Acme Corp", "role": "admin", "perms": 27},
448
+ ]
449
+
450
+
451
+ def test_render_orgs_list(capsys):
452
+ _wide_stdout()
453
+ output.render_orgs_list(_ORGS)
454
+ out = capsys.readouterr().out
455
+ assert "your orgs" in out and "· 2" in out
456
+ assert "globex" in out and "Globex Corp" in out and "acme" in out
457
+ assert "switch with fp orgs switch <slug>" in out # generic switch hint
458
+ output.configure(no_color=True, quiet=False)
459
+
460
+
461
+ def test_render_current_org_card(capsys):
462
+ _wide_stdout()
463
+ output.render_current_org(slug="globex", name="Globex Corp", role="admin",
464
+ permission_count=28, email="admin@local.host")
465
+ cap = capsys.readouterr()
466
+ out = cap.out
467
+ assert "current org" in out and "globex" in out and "Globex Corp" in out
468
+ assert "role admin" in out and "28 permissions" in out
469
+ assert "signed in as admin@local.host" in out
470
+ # footer cross-links the related commands (stderr chrome)
471
+ assert "fp orgs perms" in cap.err and "fp orgs switch <slug>" in cap.err
472
+ output.configure(no_color=True, quiet=False)
473
+
474
+
475
+ def test_render_org_perms(capsys):
476
+ _wide_stdout()
477
+ output.render_org_perms(slug="globex", role="admin", name="Globex Corp",
478
+ permissions=["dashboards:read", "dashboards:write", "keys:read", "keys:delete", "agent:use"])
479
+ out = capsys.readouterr().out
480
+ # header leads with the org name + slug + role; the count moved into the box title
481
+ assert "Globex Corp" in out and "globex" in out and "role admin" in out
482
+ assert "permissions · 5 · Globex Corp" in out
483
+ assert "dashboards" in out and "keys" in out and "agent" in out
484
+ output.configure(no_color=True, quiet=False)
485
+
486
+
487
+ def test_permissions_panel_shared_with_whoami():
488
+ # whoami and orgs perms render the SAME permissions component (no drift).
489
+ perms = ["keys:read", "keys:delete", "dashboards:read"]
490
+ a = output.render_permissions_panel(perms)
491
+ b = output.render_permissions_panel(perms)
492
+ assert type(a) is type(b) # same renderable type from the one shared helper
493
+
494
+
495
+ # --- list <kind> column-flow ------------------------------------------------
496
+
497
+
498
+ def test_render_value_list_short_no_footer(capsys):
499
+ _wide_stdout()
500
+ output.render_value_list("envs", ["prod", "dev", "staging"], description="seen across events")
501
+ cap = capsys.readouterr()
502
+ out = cap.out
503
+ assert "envs · 3 seen across events" in out
504
+ for v in ("dev", "prod", "staging"):
505
+ assert v in out
506
+ # the filter-hint footer was removed from every list kind
507
+ assert "filter" not in cap.err
508
+
509
+
510
+ def test_render_value_list_columns_and_sorted(capsys):
511
+ _wide_stdout()
512
+ vals = [f"v{i:02d}" for i in range(20)] # 20 items, COL_HEIGHT 8 → 3 columns
513
+ output.render_value_list("tools", list(reversed(vals)), description="seen across events")
514
+ out = capsys.readouterr().out
515
+ assert "tools · 20" in out
516
+ assert all(v in out for v in vals) # every value shown (no truncation)
517
+ # sorted + column-major: col0 = v00..v07, so v00 appears before v08 (col1) and v16 (col2)
518
+ assert out.index("v00") < out.index("v08") < out.index("v16")
519
+
520
+
521
+ def test_render_value_list_empty(capsys):
522
+ _wide_stdout()
523
+ output.render_value_list("error_types", [], description="seen across events")
524
+ assert "none found" in capsys.readouterr().out
525
+ output.configure(no_color=True, quiet=False)
526
+
527
+
528
+ def test_render_value_list_never_has_footer(capsys):
529
+ _wide_stdout()
530
+ output.render_value_list("models", ["gpt", "claude"])
531
+ assert "filter" not in capsys.readouterr().err # the footer was removed for all kinds
532
+ output.configure(no_color=True, quiet=False)
533
+
534
+
535
+ def test_render_value_list_narrow_caps_columns(capsys):
536
+ from rich.console import Console
537
+ output.configure(no_color=True, quiet=False)
538
+ output._stdout = Console(width=30, no_color=True) # very narrow
539
+ vals = [f"value-{i:02d}" for i in range(20)]
540
+ output.render_value_list("tools", vals)
541
+ out = capsys.readouterr().out
542
+ # never wider than the terminal; all values still present (taller, fewer columns)
543
+ assert all(len(line) <= 30 for line in out.splitlines())
544
+ assert all(v in out for v in vals)
545
+ output.configure(no_color=True, quiet=False)
546
+
547
+
548
+ # --- keys: list box + status + destructive confirm/cancel/secret -------------
549
+
550
+
551
+ def test_short_id():
552
+ assert output._short_id("1f58376d-7947-9826") == "1f58…9826"
553
+ assert output._short_id("short") == "short"
554
+
555
+
556
+ def test_key_status_cell():
557
+ output.configure(no_color=False, quiet=False)
558
+ act = output._key_status_cell("active")
559
+ rev = output._key_status_cell("revoked")
560
+ unk = output._key_status_cell("mystery")
561
+ assert "●" in act.plain and any(s.style == theme.SUCCESS for s in act.spans) # live = filled green
562
+ assert "○" in rev.plain and any(s.style == theme.ERROR for s in rev.spans) # dead = hollow red
563
+ assert "●" in unk.plain and any(s.style == theme.TEXT_DIM for s in unk.spans) # unknown = neutral
564
+
565
+
566
+ def test_render_keys_box_and_footer(capsys):
567
+ _wide_stdout()
568
+ keys = [
569
+ ApiKey(id="1f58376d", name="admin", permissions=["a", "b"], created_at="2026-06-18T05:14:00Z"),
570
+ ApiKey(id="9c22aa01", name="old", permissions=["x"], created_at="2026-06-10T00:00:00Z", revoked_at="2026-06-12T00:00:00Z"),
571
+ ]
572
+ output.render_keys(keys)
573
+ output.keys_footer(keys)
574
+ cap = capsys.readouterr()
575
+ out = cap.out
576
+ assert "api keys · 2 · active first" in out
577
+ assert "admin" in out and "active" in out and "revoked" in out
578
+ assert "06-18 05:14" in out # compact created stamp
579
+ assert "1f58376d" not in out # id hidden by default
580
+ assert out.index("admin") < out.index("old") # active key sorts above the revoked one
581
+ assert "2 keys" in cap.err and "1 active" in cap.err and "1 revoked" in cap.err
582
+ output.configure(no_color=True, quiet=False)
583
+
584
+
585
+ def test_render_keys_show_id(capsys):
586
+ _wide_stdout()
587
+ output.render_keys([ApiKey(id="1f58376d-7947-9826", name="admin", created_at="2026-06-18T05:14:00Z")], show_id=True)
588
+ assert "1f58…9826" in capsys.readouterr().out # short id column when --show-id
589
+ output.configure(no_color=True, quiet=False)
590
+
591
+
592
+ def test_print_cancelled(capsys):
593
+ output.configure(no_color=True, quiet=False)
594
+ output.print_cancelled()
595
+ err = capsys.readouterr().err # boxed chrome → stderr
596
+ assert "cancelled" in err and "nothing changed" in err
597
+
598
+
599
+ def test_key_not_found(capsys):
600
+ output.configure(no_color=True, quiet=False)
601
+ output.key_not_found("admn")
602
+ err = capsys.readouterr().err # red error box → stderr
603
+ assert "error" in err and "no key named" in err and "admn" in err
604
+ assert "fp keys list" in err # hint
605
+
606
+
607
+ def test_render_secret_box(capsys):
608
+ output.configure(no_color=True, quiet=False)
609
+ output.render_secret_box("admin", "a" * 64)
610
+ err = capsys.readouterr().err
611
+ assert "secret rotated" in err and "new secret for key" in err and "admin" in err
612
+ assert "a" * 64 in err and "shown once" in err
613
+ output.configure(no_color=True, quiet=False)
614
+
615
+
616
+ def test_key_disabled_box(capsys):
617
+ output.configure(no_color=True, quiet=False)
618
+ output.key_disabled("admin")
619
+ err = capsys.readouterr().err # green boxed result → stderr (scripts use --json / exit code)
620
+ assert "disabled" in err and "admin" in err and "it can no longer be used" in err
621
+
622
+
623
+ # --- users: list box + identity cards + permission diff ----------------------
624
+
625
+
626
+ def _du(**over) -> DashboardUser:
627
+ base = dict(id="u1", email="a@test", permissions=[], permission_set=None,
628
+ permission_added=[], permission_removed=[], disabled_at=None,
629
+ is_protected=False, created_at="2026-06-25T08:00:00Z", updated_at="")
630
+ base.update(over)
631
+ return DashboardUser(**base)
632
+
633
+
634
+ def test_fmt_user_joined():
635
+ assert output._fmt_user_joined("2026-06-25T08:00:00Z", False) == "06-25"
636
+ assert output._fmt_user_joined("2026-06-25T08:00:00Z", True) == "2026-06-25" # spans years
637
+ assert output._fmt_user_joined("nope", False) == "-"
638
+
639
+
640
+ def test_user_status_cell():
641
+ output.configure(no_color=False, quiet=False)
642
+ act = output._user_status_cell(False)
643
+ dis = output._user_status_cell(True)
644
+ assert "● active" in act.plain and any(s.style == theme.SUCCESS for s in act.spans)
645
+ assert "○ disabled" in dis.plain and any(s.style == theme.ERROR for s in dis.spans)
646
+ muted = output._user_status_cell(True, muted=True)
647
+ assert any(s.style == theme.TEXT_DIM for s in muted.spans) # dimmed in disabled list rows
648
+ output.configure(no_color=True, quiet=False)
649
+
650
+
651
+ def test_render_users_box_and_footer(capsys):
652
+ _wide_stdout()
653
+ users = [
654
+ _du(id="u1", email="root@test", permissions=["events:read"] * 28, permission_set="admin", is_protected=True),
655
+ _du(id="u2", email="dev@test", permissions=["events:read"] * 9, permission_set="read-only"),
656
+ _du(id="u3", email="off@test", permission_set="standard", disabled_at="2026-01-01T00:00:00Z"),
657
+ ]
658
+ output.render_users(users)
659
+ output.users_footer(users)
660
+ cap = capsys.readouterr()
661
+ out, err = cap.out, cap.err
662
+ assert "users · 3" in out
663
+ for c in ("email", "access", "perms", "joined", "status"):
664
+ assert c in out
665
+ assert "root@test" in out and "admin" in out and "06-25" in out
666
+ assert "active" in out and "disabled" in out
667
+ assert "P" in out # protected lock fallback (no-color → P marker)
668
+ assert "3 users" in err and "2 active" in err and "1 disabled" in err and "1 protected" in err
669
+ output.configure(no_color=True, quiet=False)
670
+
671
+
672
+ def test_render_users_active_sorted_first(capsys):
673
+ _wide_stdout()
674
+ output.render_users([
675
+ _du(id="u2", email="off@test", disabled_at="2026-01-01T00:00:00Z"),
676
+ _du(id="u1", email="on@test", disabled_at=None),
677
+ ])
678
+ out = capsys.readouterr().out
679
+ assert out.index("on@test") < out.index("off@test") # active above disabled
680
+ output.configure(no_color=True, quiet=False)
681
+
682
+
683
+ def test_render_users_empty(capsys):
684
+ _wide_stdout()
685
+ output.render_users([])
686
+ assert "no users" in capsys.readouterr().out
687
+ output.configure(no_color=True, quiet=False)
688
+
689
+
690
+ def test_render_user_show_card_and_perms(capsys):
691
+ _wide_stdout()
692
+ u = _du(email="dev@test", permissions=["keys:read", "keys:delete", "dashboards:read"],
693
+ permission_set="admin", is_protected=True)
694
+ output.render_user_show(u)
695
+ out = capsys.readouterr().out # show is a read view → stdout
696
+ assert "user" in out and "dev@test" in out and "protected" in out
697
+ assert "access admin" in out and "3 permissions" in out and "active" in out
698
+ assert "permissions · 3" in out and "keys" in out and "dashboards" in out
699
+ output.configure(no_color=True, quiet=False)
700
+
701
+
702
+ def test_render_user_created_green_card(capsys):
703
+ _wide_stdout()
704
+ u = _du(email="new@test", permissions=["events:read", "keys:read"], permission_set="read-only")
705
+ output.render_user_created(u)
706
+ err = capsys.readouterr().err # write result → stderr chrome
707
+ assert "user created" in err and "new@test" in err
708
+ assert "access read-only" in err and "2 permissions" in err and "active" in err
709
+ assert "permissions · 2 · read-only" in err # suffix shows the set
710
+ output.configure(no_color=True, quiet=False)
711
+
712
+
713
+ def test_render_user_updated_diff(capsys):
714
+ _wide_stdout() # no-color → +/- prefixes for the diff
715
+ result = _du(email="dev@test", permissions=["users:read", "users:create", "keys:read"], permission_set="admin")
716
+ union = sorted({"users:read", "users:write", "keys:read"} | {"users:read", "users:create", "keys:read"})
717
+ output.render_user_updated(result, added=["users:create"], removed=["users:write"], union=union)
718
+ err = capsys.readouterr().err
719
+ assert "permissions updated · dev@test" in err
720
+ assert "now 3" in err and "+1 added" in err and "−1 removed" in err
721
+ assert "permissions · 3" in err # NEW set size (struck removal not counted)
722
+ assert "+create" in err and "-write" in err # added chip / removed ghost (no-color prefixes)
723
+ assert "unchanged" in err # legend
724
+ output.configure(no_color=True, quiet=False)
725
+
726
+
727
+ def test_user_notice_boxes(capsys):
728
+ output.configure(no_color=True, quiet=False)
729
+ output.user_not_found("ghost@test")
730
+ output.user_disabled("dev@test")
731
+ output.user_enabled("dev@test")
732
+ output.user_no_change()
733
+ err = capsys.readouterr().err # all notice boxes → stderr
734
+ assert "no user with email" in err and "ghost@test" in err and "fp users list" in err
735
+ assert "can no longer sign in" in err and "fp users enable dev@test" in err
736
+ assert "they can sign in again" in err
737
+ assert "no change" in err and "already match" in err
738
+
739
+
740
+ # --- saved queries: list box + show (card + highlighted SQL) ----------------
741
+
742
+
743
+ def _sq(**over) -> SavedQuery:
744
+ base = dict(id="q1", name="errs", description="", sql_text="select 1", params=[],
745
+ created_by="system", created_at="2026-06-18T05:14:51Z", updated_at="")
746
+ base.update(over)
747
+ return SavedQuery(**base)
748
+
749
+
750
+ def test_render_queries_box(capsys):
751
+ _wide_stdout()
752
+ qs = [
753
+ _sq(name="q_eval_score_avg", description="Average value for each score key. " * 6, created_by="system"),
754
+ _sq(name="q_eval_total", description="Count by KPI tiles.", created_by="alice@example.com"),
755
+ ]
756
+ output.render_queries(qs)
757
+ cap = capsys.readouterr()
758
+ out, err = cap.out, cap.err
759
+ assert "saved queries · 2" in out # the count glows in the title
760
+ for c in ("name", "description", "created by", "created"):
761
+ assert c in out
762
+ assert "q_eval_score_avg" in out and "06-18" in out
763
+ assert "…" in out # long description truncated to one line
764
+ assert "updated" not in out # updated_at not shown
765
+ assert "run one with" not in err # the run-hint footer was removed
766
+
767
+
768
+ def test_render_queries_empty(capsys):
769
+ _wide_stdout()
770
+ output.render_queries([])
771
+ assert "no saved queries" in capsys.readouterr().out
772
+ output.configure(no_color=True, quiet=False)
773
+
774
+
775
+ def test_render_query_show_card_and_sql(capsys):
776
+ _wide_stdout()
777
+ q = _sq(name="q_eval_score_avg", description="Average value for each score key.",
778
+ sql_text="SELECT score_key,\n avg(score_val) AS value\nFROM analytics.evaluations")
779
+ output.render_query_show(q)
780
+ out = capsys.readouterr().out
781
+ assert "q_eval_score_avg · saved query" in out
782
+ assert "Average value for each score key." in out # full description
783
+ assert "created by system · created 2026-06-18" in out # created_at, not updated_at
784
+ assert "sql · clickhouse" in out
785
+ assert "SELECT" in out and "avg" in out and "FROM" in out # full SQL, no truncation
786
+ assert "1" in out and "2" in out and "3" in out # line numbers
787
+ output.configure(no_color=True, quiet=False)
788
+
789
+
790
+ def test_query_not_found_boxed(capsys):
791
+ _wide_stdout()
792
+ output.query_not_found("nope")
793
+ err = capsys.readouterr().err # red `error` notice box
794
+ assert "error" in err and "no query named" in err and "nope" in err and "fp query list" in err
795
+ output.configure(no_color=True, quiet=False)
796
+
797
+
798
+ def test_query_write_feedback_boxed(capsys):
799
+ _wide_stdout()
800
+ output.query_exists("dup")
801
+ output.query_failed("Syntax error near 'FORM'")
802
+ output.query_deleted("errs")
803
+ output.query_cancelled("nothing deleted")
804
+ err = capsys.readouterr().err
805
+ assert "a query named" in err and "dup" in err and "fp query update dup" in err
806
+ assert "query failed — Syntax error near 'FORM'" in err and "check your query and rerun it" in err
807
+ assert "deleted saved query" in err and "errs" in err
808
+ assert "cancelled" in err and "nothing deleted" in err # boxed (title `cancelled` + body)
809
+ output.configure(no_color=True, quiet=False)
810
+
811
+
812
+ def test_query_failed_permission_has_no_query_hint(capsys):
813
+ _wide_stdout()
814
+ output.query_failed("Your account lacks permission", permission=True)
815
+ err = capsys.readouterr().err
816
+ assert "Your account lacks permission" in err
817
+ assert "check your query" not in err # permission error → no query-fix hint
818
+ output.configure(no_color=True, quiet=False)
819
+
820
+
821
+ def test_render_query_created_and_updated(capsys):
822
+ _wide_stdout()
823
+ q = _sq(name="sample_query_1", description="sample query", sql_text="SELECT * FROM tables")
824
+ output.render_query_created(q)
825
+ err = capsys.readouterr().err # write result → stderr
826
+ assert "query created" in err and "sample_query_1" in err and "sample query" in err
827
+ assert "created by you · just now" in err
828
+ assert "SELECT" in err and "FROM" in err # numbered sql box
829
+ assert "run it with" not in err # the run-hint footer was removed
830
+
831
+ q2 = _sq(name="sample_query1", description="hi", sql_text="SELECT 1")
832
+ output.render_query_updated(q2, old_name="sample_query_1")
833
+ err2 = capsys.readouterr().err
834
+ assert "query updated" in err2 and "sample_query1" in err2 and "was sample_query_1" in err2 # rename shown
835
+ output.configure(no_color=True, quiet=False)
836
+
837
+
838
+ def test_render_query_delete_preview(capsys):
839
+ output.configure(no_color=True, quiet=False)
840
+ q = _sq(name="q_eval_total", description="Count of evaluations matching filters. Used by KPI tiles.",
841
+ created_by="system")
842
+ output.render_query_delete_preview(q)
843
+ err = capsys.readouterr().err # amber preview box → stderr
844
+ assert "delete saved query" in err and "q_eval_total" in err
845
+ assert "created by system · 2026-06-18" in err
846
+
847
+
848
+ def _qr(columns, rows, elapsed_ms=12) -> QueryResult:
849
+ return QueryResult(columns=columns, rows=rows, truncated=False, elapsed_ms=elapsed_ms)
850
+
851
+
852
+ def test_render_query_result_scalar(capsys):
853
+ _wide_stdout()
854
+ output.render_query_result("q_eval_total", _qr([{"name": "total", "type": "UInt64"}], [["1284"]], 6))
855
+ out = capsys.readouterr().out
856
+ assert "q_eval_total · 1 row · 6ms" in out
857
+ assert "1,284" in out and "total" in out # thousands separator + column-name label
858
+ output.configure(no_color=True, quiet=False)
859
+
860
+
861
+ def test_render_query_result_record(capsys):
862
+ _wide_stdout()
863
+ res = _qr([{"name": "session", "type": "String"}, {"name": "score", "type": "Float64"}],
864
+ [["sess-2026-4b90", "0.912"]], 8)
865
+ output.render_query_result("latest_run", res)
866
+ out = capsys.readouterr().out
867
+ assert "latest_run · 1 row · 8ms" in out
868
+ assert "session" in out and "sess-2026-4b90" in out and "score" in out and "0.912" in out
869
+ output.configure(no_color=True, quiet=False)
870
+
871
+
872
+ def test_render_query_result_table_and_null(capsys):
873
+ _wide_stdout()
874
+ res = _qr([{"name": "score_key", "type": "String"}, {"name": "value", "type": "Float64"}, {"name": "n", "type": "UInt64"}],
875
+ [["helpfulness", "0.847", "285"], [None, "0.503", "11"]], 12)
876
+ output.render_query_result("q_eval_score_avg", res)
877
+ cap = capsys.readouterr()
878
+ out = cap.out
879
+ assert "q_eval_score_avg · 2 rows · 12ms" in out
880
+ assert "helpfulness" in out and "0.847" in out and "285" in out
881
+ assert "null" in out # None cell → 'null', not '-'
882
+ assert "2 rows" in cap.err and "3 columns" in cap.err # footer
883
+ output.configure(no_color=True, quiet=False)
884
+
885
+
886
+ def test_render_query_result_empty(capsys):
887
+ _wide_stdout()
888
+ output.render_query_result("sample_query", _qr([{"name": "n", "type": "UInt64"}], [], 3))
889
+ assert "no rows returned" in capsys.readouterr().out
890
+ output.configure(no_color=True, quiet=False)
891
+
892
+
893
+ def test_render_query_result_row_cap(capsys):
894
+ _wide_stdout()
895
+ rows = [[str(i), str(i * 2)] for i in range(120)]
896
+ res = _qr([{"name": "a", "type": "UInt64"}, {"name": "b", "type": "UInt64"}], rows, 50)
897
+ output.render_query_result("big", res, row_cap=50)
898
+ cap = capsys.readouterr()
899
+ assert "showing 50 of 120 rows" in cap.err and "query run" in cap.err # capped footer + --json pointer
900
+ output.configure(no_color=True, quiet=False)
901
+
902
+
903
+ def test_render_query_schema(capsys):
904
+ _wide_stdout()
905
+ data = {"schema": "analytics", "tables": [
906
+ {"name": "events", "columns": [{"name": "id", "type": "int"}, {"name": "tool_name", "type": "string?"}]},
907
+ {"name": "evaluations", "columns": [{"name": "status", "type": "string"}]},
908
+ ]}
909
+ output.render_query_schema(data)
910
+ output.schema_footer(2, 3)
911
+ cap = capsys.readouterr()
912
+ out, err = cap.out, cap.err
913
+ assert "schema · analytics · 2 tables · 3 columns" in out
914
+ assert "events" in out and "evaluations" in out
915
+ assert out.count("events") == 1 # table name printed once per group (not per column)
916
+ assert "tool_name" in out and "?" in out # nullable marker
917
+ assert "2 tables" in err and "nullable" in err # footer legend
918
+ output.configure(no_color=True, quiet=False)
919
+
920
+
921
+ def test_schema_type_cell_categories():
922
+ output.configure(no_color=False, quiet=False)
923
+ assert output._schema_type_cell("int").style == theme.PINK
924
+ assert output._schema_type_cell("string").style == theme.SUCCESS
925
+ assert output._schema_type_cell("uuid").style == theme.BLUE
926
+ assert output._schema_type_cell("timestamp").style == theme.BLUE
927
+ assert output._schema_type_cell("Bool").style == theme.AMBER
928
+ nullable = output._schema_type_cell("string?")
929
+ assert nullable.plain == "string ?" and nullable.style == theme.SUCCESS # base green + dim ? split off
930
+ output.configure(no_color=True, quiet=False)
931
+
932
+
933
+ # --- alerts: list box + show cards (per-trigger-kind parsing) ----------------
934
+
935
+
936
+ def _alert(**over) -> Alert:
937
+ base = dict(id="a1", name="alert", description=None, enabled=True, trigger_kind="metric_threshold",
938
+ trigger_spec={}, min_breaches=1, eval_window=1, eval_interval_secs=300, severity="warning",
939
+ channels=[], created_by="admin@local.host", created_at="2026-06-28T00:00:00Z",
940
+ updated_at="", last_attempted_at="2026-06-28T00:00:00Z", open_incidents=0)
941
+ base.update(over)
942
+ return Alert(**base)
943
+
944
+
945
+ def test_humanize_secs():
946
+ assert output.humanize_secs(300) == "5m"
947
+ assert output.humanize_secs(900) == "15m"
948
+ assert output.humanize_secs(3600) == "1h"
949
+ assert output.humanize_secs(86400) == "1d"
950
+ assert output.humanize_secs(45) == "45s"
951
+ assert output.humanize_secs(60) == "1m"
952
+ assert output.humanize_secs(None) == "-"
953
+
954
+
955
+ def test_severity_and_status_cells():
956
+ output.configure(no_color=False, quiet=False)
957
+ assert output._severity_cell("critical").style == theme.ERROR
958
+ assert output._severity_cell("warning").style == theme.AMBER
959
+ assert output._severity_cell("info").style == theme.TEXT_DIM
960
+ assert output._severity_cell("weird").style == theme.TEXT_DIM # unknown → neutral
961
+ on = output._alert_status_cell(True)
962
+ off = output._alert_status_cell(False)
963
+ assert "● on" in on.plain and any(s.style == theme.SUCCESS for s in on.spans)
964
+ assert "○ off" in off.plain
965
+ output.configure(no_color=True, quiet=False)
966
+
967
+
968
+ def test_render_alerts_box_and_footer(capsys):
969
+ _wide_stdout()
970
+ alerts = [
971
+ _alert(name="live", trigger_kind="custom_sql", severity="critical", enabled=True, open_incidents=1),
972
+ _alert(name="off1", trigger_kind="metric_threshold", severity="warning", enabled=False,
973
+ last_attempted_at=None, created_at="2026-06-20T00:00:00Z"),
974
+ ]
975
+ output.render_alerts(alerts)
976
+ output.alerts_footer(alerts)
977
+ cap = capsys.readouterr()
978
+ out, err = cap.out, cap.err
979
+ assert "alerts · 2 · newest first" in out
980
+ for c in ("created", "name", "by", "trigger", "severity", "last alert"): # no status column
981
+ assert c in out
982
+ assert "status" not in out # the status column was removed
983
+ assert "live" in out and "admin@local.host" in out # the actual creator, not "you"
984
+ assert "never" in out
985
+ assert out.index("live") < out.index("off1") # newest first
986
+ assert "2 alerts" in err and "1 on" in err and "1 off" in err # on/off split in the footer
987
+ assert "1 critical" in err and "1 warning" in err
988
+ output.configure(no_color=True, quiet=False)
989
+
990
+
991
+ def test_render_alert_show_metric_threshold(capsys):
992
+ _wide_stdout()
993
+ a = _alert(name="metric-threshold-alert", trigger_kind="metric_threshold", severity="warning",
994
+ trigger_spec={"filter": {"environment": "production", "event_type": "tool_call"},
995
+ "metric": "error_count", "op": ">", "value": 50, "window_secs": 900},
996
+ channels=[])
997
+ output.render_alert_show(a)
998
+ out = capsys.readouterr().out
999
+ assert "metric-threshold-alert" in out and "warning" in out and "enabled" in out
1000
+ assert "0 open incidents" in out
1001
+ assert "trigger · metric threshold" in out
1002
+ assert "fire when error_count > 50 over 15m" in out
1003
+ assert "environment = production" in out and "event_type = tool_call" in out
1004
+ assert "window 1" in out and "min breaches 1" in out and "checks every 5m" in out
1005
+ assert "channels · default" in out and "slack" in out and "default webhook" in out
1006
+ output.configure(no_color=True, quiet=False)
1007
+
1008
+
1009
+ def test_render_alert_show_custom_sql(capsys):
1010
+ _wide_stdout()
1011
+ a = _alert(trigger_kind="custom_sql",
1012
+ trigger_spec={"op": ">", "query_name": "sample-query", "sql": "SELECT model\nFROM analytics.events", "value": 10})
1013
+ output.render_alert_show(a)
1014
+ out = capsys.readouterr().out
1015
+ assert "fire when query sample-query > 10 rows" in out
1016
+ assert "SELECT" in out and "FROM" in out # SQL via Syntax box inside the card
1017
+ output.configure(no_color=True, quiet=False)
1018
+
1019
+
1020
+ def test_render_alert_show_evaluation_score(capsys):
1021
+ _wide_stdout()
1022
+ a = _alert(trigger_kind="evaluation_score",
1023
+ trigger_spec={"environment": "dev", "min_count": 3, "op": ">", "score_key": "hallucination", "value": 0.8, "window_secs": 3600})
1024
+ output.render_alert_show(a)
1025
+ out = capsys.readouterr().out
1026
+ assert "fire when hallucination > 0.8 (min 3) over 1h" in out
1027
+ assert "environment" in out and "dev" in out
1028
+ output.configure(no_color=True, quiet=False)
1029
+
1030
+
1031
+ def test_render_alert_show_per_event(capsys):
1032
+ _wide_stdout()
1033
+ a = _alert(trigger_kind="per_event",
1034
+ trigger_spec={"agent_id": "agent_id", "environment": "dev", "error_type": "RuntimeError",
1035
+ "event_type": "error", "lookback_secs": 60, "message_contains": "runtimeerror", "tool_name": "bash"})
1036
+ output.render_alert_show(a)
1037
+ out = capsys.readouterr().out
1038
+ assert "fire on error events within 1m" in out
1039
+ assert "tool_name" in out and "bash" in out and "error_type" in out and "RuntimeError" in out
1040
+ assert 'message ~' in out and "runtimeerror" in out
1041
+ output.configure(no_color=True, quiet=False)
1042
+
1043
+
1044
+ def test_render_alert_show_eval_compound(capsys):
1045
+ _wide_stdout()
1046
+ a = _alert(trigger_kind="eval_compound",
1047
+ trigger_spec={"combinator": "any", "window_secs": 3600, "min_count": 1, "environment": "dev",
1048
+ "conditions": [{"score_key": "helpfulness", "op": "<", "value": 0.5},
1049
+ {"score_key": "safety", "op": "<", "value": 0.8}]})
1050
+ output.render_alert_show(a)
1051
+ out = capsys.readouterr().out
1052
+ assert "fire when any of these over 1h:" in out
1053
+ assert "helpfulness < 0.5" in out and "safety < 0.8" in out
1054
+ assert "min count 1" in out and "environment" in out and "dev" in out
1055
+ output.configure(no_color=True, quiet=False)
1056
+
1057
+
1058
+ def test_render_alert_channels_custom_and_default():
1059
+ output.configure(no_color=False, quiet=False)
1060
+ # empty → all defaults
1061
+ all_def, _ = output._alert_channels_body([])
1062
+ assert all_def is True
1063
+ # a custom slack + default email → not all-default
1064
+ mixed, _ = output._alert_channels_body([
1065
+ {"kind": "slack", "webhook_setting_key": "my_slack_url_entered"},
1066
+ {"kind": "email", "recipients": None},
1067
+ ])
1068
+ assert mixed is False
1069
+ # alerts.-prefixed key counts as default
1070
+ defaulted, _ = output._alert_channels_body([{"kind": "slack", "webhook_setting_key": "alerts.slack_default_webhook"}])
1071
+ assert defaulted is True
1072
+ output.configure(no_color=True, quiet=False)
1073
+
1074
+
1075
+ def test_render_alert_created_and_updated(capsys):
1076
+ _wide_stdout()
1077
+ a = _alert(name="errs", trigger_kind="metric_threshold", severity="warning", enabled=True,
1078
+ trigger_spec={"metric": "error_count", "op": ">", "value": 50, "window_secs": 900})
1079
+ output.render_alert_created(a)
1080
+ out = capsys.readouterr().out # write result → stdout (data)
1081
+ assert "alert created" in out and "errs" in out
1082
+ assert "warning" in out and "enabled" in out # identity line
1083
+ assert "created by you · just now" in out
1084
+ assert "trigger · metric threshold" in out and "fire when error_count > 50 over 15m" in out
1085
+ assert "evaluation" in out and "channels" in out # full config cards
1086
+
1087
+ b = _alert(name="errs2", trigger_kind="metric_threshold", severity="critical")
1088
+ output.render_alert_updated(b, old_name="errs")
1089
+ out2 = capsys.readouterr().out
1090
+ assert "alert updated" in out2 and "errs2" in out2 and "was errs" in out2 # rename shown
1091
+ assert "updated by you · just now" in out2
1092
+ output.configure(no_color=True, quiet=False)
1093
+
1094
+
1095
+ def test_render_alert_delete_preview_and_feedback(capsys):
1096
+ output.configure(no_color=True, quiet=False)
1097
+ a = _alert(name="old-alert", severity="warning", open_incidents=2)
1098
+ output.render_alert_delete_preview(a)
1099
+ output.alert_deleted("old-alert")
1100
+ output.cancelled_plain("nothing deleted")
1101
+ output.alert_not_found("ghost")
1102
+ err = capsys.readouterr().err
1103
+ assert "delete alert" in err and "old-alert" in err and "2 open incidents" in err
1104
+ assert "deleted alert old-alert" in err
1105
+ assert "cancelled — nothing deleted" in err
1106
+ assert "no alert named" in err and "ghost" in err and "fp alerts list" in err
1107
+
1108
+
1109
+ def test_alert_exists_and_test_sent(capsys):
1110
+ output.configure(no_color=True, quiet=False)
1111
+ output.alert_exists("dup")
1112
+ output.alert_test_sent("p95", ["slack", "email"])
1113
+ err = capsys.readouterr().err
1114
+ assert "an alert named" in err and "dup" in err and "fp alerts update dup" in err
1115
+ assert "test notification sent for" in err and "p95" in err
1116
+ assert "dispatched to" in err and "slack" in err and "email" in err
1117
+ assert "delivery isn't confirmed" in err
1118
+
1119
+
1120
+ # --- settings: list box + schema box + set card -----------------------------
1121
+
1122
+
1123
+ class _Setting:
1124
+ def __init__(self, key, value, schema=None, updated_at="2026-06-25T16:00:00Z", updated_by=None):
1125
+ self.key, self.value, self.schema = key, value, schema or {}
1126
+ self.updated_at, self.updated_by, self.scope = updated_at, updated_by, None
1127
+
1128
+
1129
+ def test_setting_value_text_type_aware():
1130
+ output.configure(no_color=False, quiet=False)
1131
+ assert output._setting_value_text(86400, "positive_int").style == theme.PINK # numeric pink
1132
+ assert output._setting_value_text(["a", "b"], "email_list").plain == "a, b" # list joined
1133
+ assert output._setting_value_text("x", "secret").plain == "(secret)" # secret masked
1134
+ assert output._setting_value_text("", "url").plain == "(unset)" # empty
1135
+ assert output._setting_value_text([], "channel_set").plain == "(none)" # empty list
1136
+ output.configure(no_color=True, quiet=False)
1137
+
1138
+
1139
+ def test_render_settings_box(capsys):
1140
+ _wide_stdout()
1141
+ rows = [
1142
+ _Setting("session_ttl_secs", 86400, {"kind": "positive_int"}),
1143
+ _Setting("alerts.webhook_signing_secret", "", {"kind": "secret"}),
1144
+ _Setting("alerts.email_default_recipients", ["admin@local.host"], {"kind": "email_list"}),
1145
+ ]
1146
+ output.render_settings(rows)
1147
+ cap = capsys.readouterr()
1148
+ out, err = cap.out, cap.err
1149
+ assert "settings · 3" in out # the count glows in the title
1150
+ for c in ("key", "value", "type", "updated"):
1151
+ assert c in out
1152
+ assert "session_ttl_secs" in out and "86400" in out and "integer" in out
1153
+ assert "(secret)" in out # secret never echoed
1154
+ assert "change one with" not in err # the footer hint was removed
1155
+ output.configure(no_color=True, quiet=False)
1156
+
1157
+
1158
+ def test_render_settings_schema_accepts(capsys):
1159
+ _wide_stdout()
1160
+ entries = [
1161
+ {"key": "session_ttl_secs", "kind": "positive_int", "min": 60, "max": 2592000, "unit": "seconds",
1162
+ "description": "session lifetime"},
1163
+ {"key": "alerts.enabled_channels", "kind": "channel_set", "options": ["email", "slack", "webhook"],
1164
+ "description": "channels"},
1165
+ ]
1166
+ output.render_settings_schema(entries)
1167
+ out = capsys.readouterr().out
1168
+ assert "settings schema · 2" in out
1169
+ assert "60–2592000 seconds" in out # int range + unit
1170
+ assert "email · slack · webhook" in out # channel options
1171
+ output.configure(no_color=True, quiet=False)
1172
+
1173
+
1174
+ def test_render_setting_updated_card(capsys):
1175
+ _wide_stdout()
1176
+ output.render_setting_updated(_Setting("session_ttl_secs", 3600, {"kind": "positive_int"}), "positive_int")
1177
+ out = capsys.readouterr().out # write result → stdout
1178
+ assert "setting updated" in out and "session_ttl_secs" in out and "3600" in out
1179
+ assert "updated by you · just now" in out
1180
+ output.configure(no_color=True, quiet=False)
1181
+
1182
+
1183
+ def test_setting_feedback_lines(capsys):
1184
+ output.configure(no_color=True, quiet=False)
1185
+ output.setting_not_found("nope")
1186
+ output.setting_no_change("session_ttl_secs", 86400, "positive_int")
1187
+ output.setting_failed("value must be between 60 and 2592000")
1188
+ err = capsys.readouterr().err
1189
+ assert "no setting named" in err and "nope" in err and "fp settings list" in err
1190
+ assert "no change" in err and "session_ttl_secs" in err and "already" in err
1191
+ assert "value must be between 60 and 2592000" in err
1192
+
1193
+
1194
+ # ══ incidents output tests (added) ══
1195
+
1196
+
1197
+ def _incident(**over) -> Incident:
1198
+ base = {"id": "1f5803aaaaaabbbbcccc000000009826", "alert_name": "p95 latency",
1199
+ "alert_severity": "critical", "state": "firing", "opened_at": "2026-06-20T00:00:00Z",
1200
+ "assignees": []}
1201
+ base.update(over)
1202
+ return Incident.from_dict(base)
1203
+
1204
+
1205
+ def test_incident_status_cell_enum():
1206
+ output.configure(no_color=False, quiet=False)
1207
+ fire = output._incident_status_cell("firing")
1208
+ assert fire.plain == "● firing" and any(s.style == theme.ERROR for s in fire.spans)
1209
+ ack = output._incident_status_cell("acknowledged")
1210
+ assert ack.plain == "● acknowledged" and any(s.style == theme.AMBER for s in ack.spans)
1211
+ res = output._incident_status_cell("resolved")
1212
+ assert res.plain == "○ resolved" # hollow dot → distinguishable mono
1213
+ assert output._incident_status_cell("weird").plain == "● weird" # unknown → neutral, no crash
1214
+ output.configure(no_color=True, quiet=False)
1215
+
1216
+
1217
+ def test_assignees_cell_overflow():
1218
+ output.configure(no_color=True, quiet=False)
1219
+ assert output._assignees_cell([]).plain == "—"
1220
+ assert output._assignees_cell(["a@x", "b@x", "c@x", "d@x"]).plain == "a@x, b@x +2"
1221
+
1222
+
1223
+ def test_render_incidents_box_and_footer(capsys):
1224
+ _wide_stdout()
1225
+ incs = [
1226
+ _incident(state="firing", assignees=["a@example.com"]),
1227
+ _incident(id="2266", alert_name=None, alert_severity="warning", state="acknowledged"),
1228
+ _incident(id="3399", alert_severity="info", state="resolved", opened_at="2026-06-18T00:00:00Z"),
1229
+ ]
1230
+ output.render_incidents(incs)
1231
+ output.incidents_footer(incs)
1232
+ cap = capsys.readouterr()
1233
+ out, err = cap.out, cap.err
1234
+ assert "issues · 3" in out
1235
+ for c in ("id", "alert", "severity", "state", "opened", "assignees"):
1236
+ assert c in out
1237
+ assert "1f58…9826" in out # short id (the handle)
1238
+ assert "—" in out # manual incident (no alert_name) + no assignees
1239
+ assert "firing" in out and "acknowledged" in out and "resolved" in out
1240
+ assert "3 issues" in err and "1 firing" in err and "1 resolved" in err # footer distribution
1241
+ output.configure(no_color=True, quiet=False)
1242
+
1243
+
1244
+ def test_render_incidents_show_id_full(capsys):
1245
+ _wide_stdout()
1246
+ output.render_incidents([_incident()], show_id=True)
1247
+ out = capsys.readouterr().out
1248
+ assert "1f5803aaaaaabbbbcccc000000009826" in out # full id when --show-id
1249
+ output.configure(no_color=True, quiet=False)
1250
+
1251
+
1252
+ def test_render_incident_count_card(capsys):
1253
+ _wide_stdout()
1254
+ output.render_incident_count(12)
1255
+ output.render_incident_count(4, state="firing")
1256
+ out = capsys.readouterr().out
1257
+ assert "issues" in out and "12" in out and "open issues" in out
1258
+ assert "4" in out and "firing issues" in out
1259
+ output.configure(no_color=True, quiet=False)
1260
+
1261
+
1262
+ def test_render_incident_show_sections(capsys):
1263
+ _wide_stdout()
1264
+ inc = _incident(
1265
+ state="acknowledged", acknowledged_by="ops@example.com", assignees=["a@example.com"],
1266
+ breach_summary="p95 = 1240ms > 1000ms",
1267
+ comments=[{"author_email": "ops@example.com", "body": "on it", "created_at": "2026-06-20T00:01:00Z"},
1268
+ {"author_email": "x@example.com", "body": None, "created_at": "t", "deleted_at": "t"}],
1269
+ subscribers=[{"email": "ops@example.com", "source": "ack", "subscribed_at": "2026-06-20T00:01:00Z"}],
1270
+ activity=[{"kind": "opened", "actor": "system", "at": "2026-06-20T00:00:00Z"}],
1271
+ )
1272
+ output.render_incident_show(inc)
1273
+ out = capsys.readouterr().out
1274
+ assert "p95 latency" in out and "1f58…9826" in out
1275
+ assert "acknowledged by ops@example.com" in out and "assigned to a@example.com" in out
1276
+ assert "breach" in out and "1240ms" in out
1277
+ assert "comments · 2" in out and "on it" in out and "(deleted)" in out
1278
+ assert "subscribers · 1" in out
1279
+ assert "activity · 1" in out and "opened" in out and "system" in out
1280
+ output.configure(no_color=True, quiet=False)
1281
+
1282
+
1283
+ def test_render_incident_show_omits_empty_sections_and_uses_breach_value(capsys):
1284
+ _wide_stdout()
1285
+ output.render_incident_show(_incident(alert_name=None, title="checkout 500s",
1286
+ source="manual", breach_value=1240.0))
1287
+ out = capsys.readouterr().out
1288
+ # The header used to be hardcoded to the literal "manual incident" for any
1289
+ # issue without a parent alert, which said nothing and mislabelled every
1290
+ # audit-born issue. It now shows the issue's own title and real source.
1291
+ assert "checkout 500s" in out and "manual incident" not in out
1292
+ assert "manual" in out and "breach value 1240" in out
1293
+ assert "comments" not in out and "subscribers" not in out and "activity" not in out
1294
+ output.configure(no_color=True, quiet=False)
1295
+
1296
+
1297
+ def test_incident_model_carries_title_source_and_finding_id():
1298
+ """These three shipped with the issues redesign but `from_dict` dropped
1299
+ them, so the CLI could not see the field that actually identifies a row."""
1300
+ inc = Incident.from_dict({"id": "i1", "title": "checkout 500s", "source": "audit",
1301
+ "source_finding_id": "f7", "state": "firing"})
1302
+ assert inc.title == "checkout 500s"
1303
+ assert inc.source == "audit" and inc.source_finding_id == "f7"
1304
+
1305
+
1306
+ def test_render_incidents_distinguishes_rows_by_title(capsys):
1307
+ """Only a minority of issues have an alert_name, so titling the table by
1308
+ alert left every manual and audit-born row rendering as a bare '—'."""
1309
+ _wide_stdout()
1310
+ output.render_incidents([
1311
+ _incident(id="a" * 32, title="checkout 500s", source="manual", alert_name=None),
1312
+ _incident(id="b" * 32, title="retry storm in planner", source="audit", alert_name=None),
1313
+ _incident(id="c" * 32, title="p95 latency", source="alert", alert_name="p95 latency"),
1314
+ ])
1315
+ out = capsys.readouterr().out
1316
+ assert "checkout 500s" in out and "retry storm in planner" in out
1317
+ assert "manual" in out and "audit" in out and "alert" in out
1318
+ output.configure(no_color=True, quiet=False)
1319
+
1320
+
1321
+ def test_render_incident_opened_and_comment_added_cards(capsys):
1322
+ _wide_stdout()
1323
+ output.render_incident_opened(summary="manual page", severity="critical", state="firing")
1324
+ output.render_incident_comment_added(IncidentComment.from_dict(
1325
+ {"id": "c1", "author_email": "me@test", "body": "looking", "created_at": "t"}))
1326
+ out = capsys.readouterr().out # write-result cards → stdout
1327
+ assert "issue opened" in out and "manual page" in out and "opened by you · just now" in out
1328
+ assert "comment added" in out and "by me@test · just now" in out and "looking" in out
1329
+ output.configure(no_color=True, quiet=False)
1330
+
1331
+
1332
+ def test_render_incident_comment_delete_preview(capsys):
1333
+ _wide_stdout()
1334
+ output.render_incident_comment_delete_preview(IncidentComment.from_dict(
1335
+ {"id": "c1", "author_email": "x@example.com", "body": "wrong incident", "created_at": "2026-06-20T00:00:00Z"}))
1336
+ err = capsys.readouterr().err # preview is stderr chrome
1337
+ assert "delete comment" in err and "x@example.com" in err and "wrong incident" in err
1338
+ output.configure(no_color=True, quiet=False)
1339
+
1340
+
1341
+ def test_render_incident_comments_and_subscribers_boxes(capsys):
1342
+ _wide_stdout()
1343
+ output.render_incident_comments([
1344
+ IncidentComment.from_dict({"id": "c1", "author_email": "ops@example.com", "body": "db pool", "created_at": "2026-06-20T00:00:00Z"}),
1345
+ IncidentComment.from_dict({"id": "c2", "author_email": "x@example.com", "body": None, "created_at": "t", "deleted_at": "t"}),
1346
+ ])
1347
+ output.render_incident_subscribers([
1348
+ IncidentSubscriber.from_dict({"email": "ops@example.com", "source": "creator", "subscribed_at": "2026-06-20T00:00:00Z"})])
1349
+ out = capsys.readouterr().out
1350
+ assert "comments · 2" in out and "db pool" in out and "(deleted)" in out
1351
+ assert "subscribers · 1" in out and "ops@example.com" in out and "creator" in out
1352
+ output.configure(no_color=True, quiet=False)
1353
+
1354
+
1355
+ def test_confirm_incident_resolve_headline(capsys, monkeypatch):
1356
+ output.configure(no_color=True, quiet=False)
1357
+ monkeypatch.setattr(output.typer, "confirm", lambda *a, **k: False)
1358
+ assert output.confirm_incident_resolve("1f5803aaaaaabbbbcccc000000009826", "p95 latency") is False
1359
+ err = capsys.readouterr().err
1360
+ assert "resolve issue" in err and "1f58…9826" in err and "(p95 latency)" in err and "this closes it" in err
1361
+
1362
+
1363
+ def test_incident_plain_feedback_lines(capsys):
1364
+ output.configure(no_color=True, quiet=False)
1365
+ iid = "1f5803aaaaaabbbbcccc000000009826"
1366
+ output.incident_acked(iid)
1367
+ output.incident_resolved(iid)
1368
+ output.incident_assigned(iid, ["a@example.com", "b@example.com"])
1369
+ output.incident_assigned(iid, [])
1370
+ output.incident_subscribed(iid, None)
1371
+ output.incident_unsubscribed(iid, "x@y.z")
1372
+ output.incident_comment_deleted()
1373
+ output.incident_not_found(iid)
1374
+ output.incident_comment_not_found("c0ffee001111")
1375
+ output.incident_failed("a@x.com is not an operator")
1376
+ err = capsys.readouterr().err
1377
+ assert "acknowledged issue 1f58…9826" in err
1378
+ assert "resolved issue 1f58…9826" in err
1379
+ assert "assigned 1f58…9826" in err and "a@example.com, b@example.com" in err
1380
+ assert "cleared assignees on 1f58…9826" in err
1381
+ assert "subscribed you to issue" in err and "unsubscribed x@y.z from issue" in err
1382
+ assert "deleted comment" in err
1383
+ assert "no issue 1f58…9826" in err and "fp issues list" in err
1384
+ assert "no comment" in err and "c0ff…1111" in err
1385
+ assert "a@x.com is not an operator" in err
1386
+
1387
+
1388
+ # ══ agent output tests (added) ══
1389
+
1390
+
1391
+ def test_msg_text_extracts_str_and_dict():
1392
+ assert output._msg_text("plain") == "plain"
1393
+ assert output._msg_text({"text": "wrapped"}) == "wrapped"
1394
+ assert output._msg_text({"foo": 1}) == ""
1395
+ assert output._msg_text(None) == ""
1396
+
1397
+
1398
+ def test_render_agent_health_configured(capsys):
1399
+ _wide_stdout()
1400
+ output.render_agent_health(configured=True, default_model="claude-x", model_count=3)
1401
+ out = capsys.readouterr().out # data view → stdout
1402
+ assert "assistant" in out and "configured" in out
1403
+ assert "default model claude-x" in out and "3 models available" in out
1404
+ output.configure(no_color=True, quiet=False)
1405
+
1406
+
1407
+ def test_render_agent_health_not_configured_omits_optional_lines(capsys):
1408
+ _wide_stdout()
1409
+ output.render_agent_health(configured=False, default_model=None, model_count=0)
1410
+ out = capsys.readouterr().out
1411
+ assert "not configured" in out
1412
+ assert "default model" not in out and "available" not in out # omitted when none
1413
+ output.configure(no_color=True, quiet=False)
1414
+
1415
+
1416
+ def test_render_agent_models_marks_default(capsys):
1417
+ _wide_stdout()
1418
+ output.render_agent_models(["m-default", "m-fast"], default_model="m-default")
1419
+ out = capsys.readouterr().out
1420
+ assert "models · 2" in out and "m-default" in out and "m-fast" in out and "default" in out
1421
+ output.configure(no_color=True, quiet=False)
1422
+
1423
+
1424
+ def test_render_agent_models_empty(capsys):
1425
+ _wide_stdout()
1426
+ output.render_agent_models([], default_model=None)
1427
+ out = capsys.readouterr().out
1428
+ assert "models · 0" in out and "no models reported" in out
1429
+ output.configure(no_color=True, quiet=False)
1430
+
1431
+
1432
+ def test_render_agent_chats_box(capsys):
1433
+ _wide_stdout()
1434
+ chats = [
1435
+ {"id": "07854990-dade-4dea-aaaa", "title": "older", "message_count": 2, "updated_at": "2026-06-20T10:00:00Z"},
1436
+ {"id": "17bf35c3-3a5a-4ff7-bbbb", "title": "newer", "message_count": 5, "updated_at": "2026-06-27T10:00:00Z"},
1437
+ ]
1438
+ output.render_agent_chats(chats)
1439
+ cap = capsys.readouterr()
1440
+ out, err = cap.out, cap.err
1441
+ assert "chats · 2" in out # the count glows in the title
1442
+ for c in ("chat-id", "title", "messages", "updated"):
1443
+ assert c in out
1444
+ assert "older" in out and "newer" in out
1445
+ assert "07854990" in out and "17bf35c3" in out # short copy-friendly handle (first 8)
1446
+ assert "dade" not in out # the rest of the id is not shown
1447
+ assert out.index("newer") < out.index("older") # newest first
1448
+ assert "open one with" not in err # the footer hint was removed
1449
+ output.configure(no_color=True, quiet=False)
1450
+
1451
+
1452
+ def test_render_agent_chats_empty(capsys):
1453
+ _wide_stdout()
1454
+ output.render_agent_chats([])
1455
+ assert "no chats" in capsys.readouterr().out
1456
+ output.configure(no_color=True, quiet=False)
1457
+
1458
+
1459
+ def test_render_agent_show_thread(capsys):
1460
+ _wide_stdout()
1461
+ output.render_agent_show(title="perf review", chat_id="conv-123456789", messages=[
1462
+ {"role": "user", "content": {"text": "why slow?"}},
1463
+ {"role": "assistant", "content": "because cache"},
1464
+ ])
1465
+ out = capsys.readouterr().out # transcript → stdout
1466
+ assert "perf review" in out and "2 messages" in out
1467
+ assert "you" in out and "assistant" in out
1468
+ assert "why slow?" in out and "because cache" in out
1469
+ output.configure(no_color=True, quiet=False)
1470
+
1471
+
1472
+ def test_render_agent_show_empty(capsys):
1473
+ _wide_stdout()
1474
+ output.render_agent_show(title="", chat_id="c1", messages=[])
1475
+ out = capsys.readouterr().out
1476
+ assert "untitled" in out and "no messages yet" in out
1477
+ output.configure(no_color=True, quiet=False)
1478
+
1479
+
1480
+ def test_render_agent_renamed_card(capsys):
1481
+ _wide_stdout()
1482
+ output.render_agent_renamed(chat_id="c1", title="new title", old_title="old title")
1483
+ err = capsys.readouterr().err # write result → stderr
1484
+ assert "chat renamed" in err and "new title" in err and "was old title" in err
1485
+ output.configure(no_color=True, quiet=False)
1486
+
1487
+
1488
+ def test_render_agent_delete_preview_and_feedback(capsys):
1489
+ _wide_stdout()
1490
+ output.render_agent_delete_preview(title="perf review", message_count=4, chat_id="07854990-dade")
1491
+ output.agent_deleted("perf review")
1492
+ output.print_cancelled("nothing deleted") # boxed cancel, like the command uses
1493
+ err = capsys.readouterr().err
1494
+ assert "delete chat" in err and "perf review" in err and "4 messages" in err
1495
+ assert "07854990" in err and "dade" not in err # short id in the preview
1496
+ assert "deleted chat" in err and "perf review" in err # boxed deleted
1497
+ assert "cancelled" in err and "nothing deleted" in err # boxed cancel
1498
+ output.configure(no_color=True, quiet=False)
1499
+
1500
+
1501
+ def test_confirm_agent_delete(monkeypatch, capsys):
1502
+ _wide_stdout()
1503
+ monkeypatch.setattr("typer.confirm", lambda *a, **k: True)
1504
+ assert output.confirm_agent_delete() is True
1505
+ monkeypatch.setattr("typer.confirm", lambda *a, **k: False)
1506
+ assert output.confirm_agent_delete() is False
1507
+ assert "permanently removes the chat" in capsys.readouterr().err
1508
+ output.configure(no_color=True, quiet=False)
1509
+
1510
+
1511
+ def test_agent_ask_chrome_lines(capsys):
1512
+ _wide_stdout()
1513
+ output.agent_tool_used("run_query")
1514
+ output.render_agent_new_chat("07854990-dade-4dea")
1515
+ output.agent_error("assistant error: boom")
1516
+ output.agent_chat_not_found("07854990-dade-4dea")
1517
+ output.agent_unconfigured_note()
1518
+ err = capsys.readouterr().err
1519
+ assert "used tool: run_query" in err
1520
+ assert "new chat" in err and 'fp agent ask --chat 07854990 "…"' in err # short id, positional msg
1521
+ assert "assistant error: boom" in err
1522
+ assert "chat not found" in err and "fp agent chats" in err # boxed not-found
1523
+ assert "isn't configured" in err and "fp agent health" in err
1524
+ output.configure(no_color=True, quiet=False)
1525
+
1526
+
1527
+
1528
+ # ── review round: renderers that described the wrong thing ───────────────────
1529
+
1530
+
1531
+ def _history(*gens):
1532
+ """`(deployment, [(id, version, effect), ...])` → the server's history shape."""
1533
+ return [{"deployment": g, "updatedAt": "2026-08-19T13:49:%02dZ" % g,
1534
+ "policies": [{"id": i, "version": v, "effect": e} for i, v, e in pols]}
1535
+ for g, pols in gens]
1536
+
1537
+
1538
+ def test_history_shows_an_effect_flip_instead_of_no_change(capsys):
1539
+ """enforce → observe is a policy that STOPPED BLOCKING, and history called
1540
+ it "no change".
1541
+
1542
+ The row identity was `id@version`, so a generation that changed only the
1543
+ effect diffed to nothing. Scanning history for "when did this stop
1544
+ blocking?" is one of the two reasons to read it at all.
1545
+ """
1546
+ _wide_stdout()
1547
+ output.render_deployment_history("m", _history(
1548
+ (1, [("guard", 1, "enforce")]),
1549
+ (2, [("guard", 1, "observe")]),
1550
+ ))
1551
+ out = capsys.readouterr().out
1552
+ assert "no change" not in out
1553
+ assert "~guard" in out
1554
+
1555
+
1556
+ def test_history_shows_a_version_bump_as_one_change(capsys):
1557
+ """A version bump split into `+guard` and `-guard` on the same row, which
1558
+ reads as removed-and-re-added rather than moved."""
1559
+ _wide_stdout()
1560
+ output.render_deployment_history("m", _history(
1561
+ (1, [("guard", 1, "enforce")]),
1562
+ (2, [("guard", 2, "enforce")]),
1563
+ ))
1564
+ # Scoped to generation 2's row: generation 1 is the machine's first, where
1565
+ # every policy is legitimately a "+".
1566
+ row = [ln for ln in capsys.readouterr().out.splitlines() if "#2" in ln][0]
1567
+ assert "~guard" in row
1568
+ assert "+guard" not in row and "-guard" not in row
1569
+
1570
+
1571
+ def test_history_still_reports_plain_adds_and_removes(capsys):
1572
+ """The `~` case must not have eaten the two it was added beside."""
1573
+ _wide_stdout()
1574
+ output.render_deployment_history("m", _history(
1575
+ (1, [("a", 1, "enforce")]),
1576
+ (2, [("a", 1, "enforce"), ("b", 1, "enforce")]),
1577
+ (3, [("b", 1, "enforce")]),
1578
+ ))
1579
+ out = capsys.readouterr().out
1580
+ assert "+b" in out and "-a" in out
1581
+
1582
+
1583
+ def test_history_says_no_change_only_when_nothing_moved(capsys):
1584
+ """A reissue that lands on an identical set is real, and should still say so."""
1585
+ _wide_stdout()
1586
+ output.render_deployment_history("m", _history(
1587
+ (1, [("a", 1, "enforce")]),
1588
+ (2, [("a", 1, "enforce")]),
1589
+ ))
1590
+ assert "no change" in capsys.readouterr().out
1591
+
1592
+
1593
+ def test_clearing_a_label_is_not_reported_as_renaming_to_blank(capsys):
1594
+ """`fp fleet rename m ""` clears the override server-side, and the card
1595
+ said `labelled m as ` — a sentence with a hole in it, describing neither
1596
+ what was asked nor what happened."""
1597
+ _wide_stdout()
1598
+ output.machine_renamed("m", "")
1599
+ # Notices go to stderr so stdout stays parseable; the box lands there.
1600
+ err = capsys.readouterr().err
1601
+ assert "cleared the label" in err
1602
+ assert "labelled m as" not in err
1603
+
1604
+ output.machine_renamed("m", "CI runner")
1605
+ assert "labelled m as CI runner" in capsys.readouterr().err.replace("\n", " ")
1606
+
1607
+
1608
+ def test_policy_list_counts_policies_and_captions_versions(capsys):
1609
+ """The panel said `policies · 4` for three policies, because the endpoint
1610
+ returns one row per immutable VERSION. The dashboard's own library counts
1611
+ distinct policies and captions the version total; this now matches it."""
1612
+ from fp_cli.models import PolicyVersion
1613
+
1614
+ def pv(pid, version):
1615
+ return PolicyVersion(id=pid, version=version, description="", sha256="",
1616
+ source=None, created_at="", created_by=None,
1617
+ disabled=False, archived=False)
1618
+
1619
+ _wide_stdout()
1620
+ output.render_policies([pv("a", 1), pv("b", 2), pv("b", 1)])
1621
+ out = capsys.readouterr().out
1622
+ assert "policies · 2 · 3 versions" in out
1623
+ # newest version of each policy first, rather than server order
1624
+ b_rows = [ln for ln in out.splitlines() if "b" in ln and "v" in ln
1625
+ and ("v1" in ln or "v2" in ln)]
1626
+ assert [("v2" in r) for r in b_rows] == [True, False], b_rows
1627
+
1628
+
1629
+ def test_policy_list_omits_the_caption_when_each_policy_has_one_version(capsys):
1630
+ from fp_cli.models import PolicyVersion
1631
+
1632
+ _wide_stdout()
1633
+ output.render_policies([PolicyVersion(id="a", version=1, description="", sha256="",
1634
+ source=None, created_at="", created_by=None,
1635
+ disabled=False, archived=False)])
1636
+ out = capsys.readouterr().out
1637
+ assert "policies · 1" in out and "versions" not in out
1638
+
1639
+
1640
+ # ─────────────────────────────────────────────────────────────────────────────
1641
+ # Decision timeline: every policy source, not just the first
1642
+ # ─────────────────────────────────────────────────────────────────────────────
1643
+ #
1644
+ # `GET /enforcement/decisions/timeline` returns ONE SERIES PER `policy_source`,
1645
+ # built server-side from a `BTreeMap<String, …>` — so they arrive alphabetically
1646
+ # (`builtin`, `cloud`, `convention`, `custom`). The renderer read `series[0]`,
1647
+ # which meant the builtin row was shown and every cloud-managed decision was
1648
+ # dropped: the exact half an operator opens this command to see.
1649
+ #
1650
+ # The failure had no tell. Header counts were summed from the same truncated
1651
+ # list, so the panel agreed with itself while disagreeing with the fleet.
1652
+
1653
+
1654
+ def _two_source_payload():
1655
+ return {
1656
+ "bucketMs": 3_600_000,
1657
+ "hours": 24,
1658
+ "series": [
1659
+ {"source": "builtin", "points": [{"t": 1_787_250_000_000, "total": 3, "deny": 1, "instruct": 0}]},
1660
+ {
1661
+ "source": "cloud",
1662
+ "points": [
1663
+ {"t": 1_787_250_000_000, "total": 900, "deny": 400, "instruct": 50},
1664
+ {"t": 1_787_253_600_000, "total": 12, "deny": 2, "instruct": 1},
1665
+ ],
1666
+ },
1667
+ ],
1668
+ }
1669
+
1670
+
1671
+ def test_timeline_merge_sums_every_series_not_just_the_first():
1672
+ from fp_cli.output import _merge_timeline_series
1673
+
1674
+ points = _merge_timeline_series(_two_source_payload())
1675
+
1676
+ # Two distinct buckets: the second exists only in the `cloud` series, so a
1677
+ # truncating implementation loses it entirely rather than under-counting it.
1678
+ assert [p["t"] for p in points] == [1_787_250_000_000, 1_787_253_600_000]
1679
+ assert points[0] == {"t": 1_787_250_000_000, "total": 903, "deny": 401, "instruct": 50}
1680
+ assert points[1] == {"t": 1_787_253_600_000, "total": 12, "deny": 2, "instruct": 1}
1681
+
1682
+ # The regression, stated as the number it produced: series[0] alone.
1683
+ assert points[0]["total"] != 3, "series[0] only — the cloud source was dropped"
1684
+
1685
+
1686
+ def test_timeline_header_counts_cover_every_source(capsys):
1687
+ """The header is summed from the same list, so it lied consistently."""
1688
+ from fp_cli.output import render_decision_timeline
1689
+
1690
+ render_decision_timeline(_two_source_payload())
1691
+ out = capsys.readouterr().out
1692
+ assert "915 evaluated" in out, out
1693
+ assert "403 blocked" in out, out
1694
+ assert "3 evaluated" not in out
1695
+
1696
+
1697
+ def test_timeline_merge_tolerates_missing_and_empty_series():
1698
+ from fp_cli.output import _merge_timeline_series
1699
+
1700
+ assert _merge_timeline_series({}) == []
1701
+ assert _merge_timeline_series({"series": []}) == []
1702
+ assert _merge_timeline_series({"series": [{}]}) == []
1703
+ assert _merge_timeline_series({"series": [{"points": None}]}) == []
1704
+
1705
+
1706
+ def test_guardrails_sparkline_uses_every_source(capsys):
1707
+ """The sparkline sat beside a headline number computed from ALL sources.
1708
+
1709
+ Asserted on the SPARKLINE GLYPHS, not on the headline counts. Those come
1710
+ from `summary["totals"]` and are correct either way, so asserting them
1711
+ passes whether or not the sparkline was built from a truncated series —
1712
+ a guard that cannot fail.
1713
+
1714
+ One cell per bucket: `series[0]` alone yields denies=[1] -> a single cell,
1715
+ the merge yields denies=[401, 2] -> two.
1716
+ """
1717
+ from fp_cli.output import render_guardrails, sparkline
1718
+
1719
+ summary = {
1720
+ "analyticsAvailable": True,
1721
+ "hours": 24,
1722
+ "policies": [],
1723
+ "deployed": [],
1724
+ "totals": {"evaluated": 915, "blocked": 403, "enforcingMachines": 2, "reportingMachines": 2},
1725
+ }
1726
+ render_guardrails(summary, _two_source_payload())
1727
+ out = capsys.readouterr().out
1728
+
1729
+ merged_spark = sparkline([401, 2])
1730
+ truncated_spark = sparkline([1])
1731
+ assert merged_spark != truncated_spark, "the fixture no longer distinguishes the two paths"
1732
+ assert merged_spark in out, f"sparkline is not drawn from every source: {out!r}"