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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +6 -6
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js +2 -2
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js +2 -2
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js +3 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js +5 -4
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js +2 -2
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +4 -3
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js +4 -4
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js +4 -4
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js +2 -2
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js +2 -2
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +66 -22
- package/.next/standalone/.next/server/app/policies/page.js +5 -4
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js +2 -2
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js +2 -2
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
- package/.next/standalone/.next/server/app/settings/page.js +1 -1
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/{[externals]__1qzqcio._.js → [externals]__0e32-7c._.js} +1 -1
- package/.next/standalone/.next/server/chunks/[externals]__1s462yl._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__01eqny5._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_0xu5z._.js +1 -1
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__1p8poxd._.js → [root-of-the-server]__0igxt32._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0neqlu-._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0t9oqnn._.js +3 -0
- package/.next/standalone/.next/server/chunks/_0bs-v8p._.js +3 -0
- package/.next/standalone/.next/server/chunks/_0tovk6q._.js +3 -0
- package/.next/standalone/.next/server/chunks/_0trp3yc._.js +3 -0
- package/.next/standalone/.next/server/chunks/_185gyk9._.js +3 -0
- package/.next/standalone/.next/server/chunks/_1ek68ln._.js +5 -6
- package/.next/standalone/.next/server/chunks/lib_telemetry-id_ts_1k8gj9o._.js +3 -0
- 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
- 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
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/src_hooks_0xbw3bu._.js +12 -0
- package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0spkm68._.js → [root-of-the-server]__07gm3zl._.js} +21 -20
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +6 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__07tbbf3._.js → [root-of-the-server]__0zig5sv._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__192nhvh._.js +5 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/{_0lvyuv4._.js → _00e9tnf._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_0u5zauv._.js → _12l4a_k._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +13 -13
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_08o7bqo._.js +10 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/middleware.js +1 -1
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +71 -27
- package/.next/standalone/.next/static/chunks/{37fcpc36x9gpe.js → 0eqky-7n7dgdm.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1ide1wl2f7zi1.js → 0sa9v5mhc8861.js} +13 -13
- package/.next/standalone/.next/static/chunks/1mfzsja6ftim5.css +2 -0
- package/.next/standalone/.next/static/chunks/{3k7mxbp9hiom0.js → 1nuqytbta44af.js} +1 -1
- package/.next/standalone/.next/static/chunks/1sl2xmn9inx3y.js +6 -0
- package/.next/standalone/.next/static/chunks/{2_2lhuojq2xop.js → 2aawsjld9ied_.js} +1 -1
- package/.next/standalone/.next/static/chunks/2gn7ut4svofm2.js +1 -0
- package/.next/standalone/.next/static/chunks/{31s1smp-g7gks.js → 2hs7ycvsav7v2.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0k5050ezjvv49.js → 2i2yt3hzjredm.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3c808qrfcq0im.js → 35xg6retpe6rp.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2zpp1we2jv6tb.js → 3y54nw0i7dsjl.js} +1 -1
- package/.next/standalone/SECURITY.md +5 -4
- package/.next/standalone/app/actions/get-hooks-config.ts +108 -17
- package/.next/standalone/app/actions/pack-actions.ts +153 -0
- package/.next/standalone/app/audit/_components/run-progress.tsx +1 -1
- package/.next/standalone/app/audit/_components/share-templates.ts +13 -13
- package/.next/standalone/app/policies/hooks-client.tsx +320 -14
- package/.next/standalone/components/navbar.tsx +2 -28
- package/.next/standalone/components/reach-developers.tsx +8 -1
- package/.next/standalone/docs-old/agenteye/python-sdk-skill.mdx +1 -1
- package/.next/standalone/docs-old/agenteye/python-sdk.mdx +59 -35
- package/.next/standalone/docs-old/i18n/README.ar.md +1 -0
- package/.next/standalone/docs-old/i18n/README.de.md +1 -0
- package/.next/standalone/docs-old/i18n/README.es.md +1 -0
- package/.next/standalone/docs-old/i18n/README.fr.md +1 -0
- package/.next/standalone/docs-old/i18n/README.he.md +1 -0
- package/.next/standalone/docs-old/i18n/README.hi.md +1 -0
- package/.next/standalone/docs-old/i18n/README.it.md +1 -0
- package/.next/standalone/docs-old/i18n/README.ja.md +1 -0
- package/.next/standalone/docs-old/i18n/README.ko.md +1 -0
- package/.next/standalone/docs-old/i18n/README.pt-br.md +1 -0
- package/.next/standalone/docs-old/i18n/README.ru.md +1 -0
- package/.next/standalone/docs-old/i18n/README.tr.md +1 -0
- package/.next/standalone/docs-old/i18n/README.vi.md +1 -0
- package/.next/standalone/docs-old/i18n/README.zh.md +1 -0
- package/.next/standalone/fp-cloud-cli/CHANGELOG.md +431 -0
- package/.next/standalone/fp-cloud-cli/LICENSE +42 -0
- package/.next/standalone/fp-cloud-cli/MANIFEST.in +10 -0
- package/.next/standalone/fp-cloud-cli/README.md +259 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/__init__.py +10 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/__main__.py +4 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/_click_compat.py +64 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/_context.py +332 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/_version.py +1 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/analytics.py +432 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/analytics_config.py +77 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/analytics_registry.py +83 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/app.py +492 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/auth.py +160 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/client.py +1694 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/__init__.py +0 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/_write.py +214 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/agent_cmds.py +407 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/alerts_cmds.py +445 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/audits_cmds.py +1054 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/auth_cmds.py +512 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/errors_cmds.py +190 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/evals_cmds.py +161 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/events_cmds.py +159 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/fleet_cmds.py +416 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/guardrails_cmds.py +148 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/incidents_cmds.py +472 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/keys_cmds.py +407 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/list_cmds.py +63 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/orgs_cmds.py +319 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/policies_cmds.py +499 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/queries_cmds.py +378 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/sessions_cmds.py +151 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/settings_cmds.py +150 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/usage_cmds.py +35 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/users_cmds.py +404 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/config.py +330 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/dates.py +78 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/enforcement.py +345 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/errors.py +98 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/models.py +891 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/orgs.py +30 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/output.py +6593 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/permissions.py +208 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/policy_check.py +290 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/py.typed +0 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/select.py +322 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/theme.py +53 -0
- package/.next/standalone/fp-cloud-cli/pyproject.toml +106 -0
- package/.next/standalone/fp-cloud-cli/skill/SKILL.md +302 -0
- package/.next/standalone/fp-cloud-cli/skill/agents/openai.yaml +8 -0
- package/.next/standalone/fp-cloud-cli/skill/references/commands.md +254 -0
- package/.next/standalone/fp-cloud-cli/tests/__init__.py +0 -0
- package/.next/standalone/fp-cloud-cli/tests/conftest.py +92 -0
- package/.next/standalone/fp-cloud-cli/tests/test_alerting.py +644 -0
- package/.next/standalone/fp-cloud-cli/tests/test_analytics.py +385 -0
- package/.next/standalone/fp-cloud-cli/tests/test_audits.py +950 -0
- package/.next/standalone/fp-cloud-cli/tests/test_auth.py +98 -0
- package/.next/standalone/fp-cloud-cli/tests/test_auth_mode.py +272 -0
- package/.next/standalone/fp-cloud-cli/tests/test_click_compat.py +118 -0
- package/.next/standalone/fp-cloud-cli/tests/test_client.py +299 -0
- package/.next/standalone/fp-cloud-cli/tests/test_commands.py +814 -0
- package/.next/standalone/fp-cloud-cli/tests/test_config.py +99 -0
- package/.next/standalone/fp-cloud-cli/tests/test_dashboards_agent.py +340 -0
- package/.next/standalone/fp-cloud-cli/tests/test_dates.py +76 -0
- package/.next/standalone/fp-cloud-cli/tests/test_enforcement_logic.py +481 -0
- package/.next/standalone/fp-cloud-cli/tests/test_facets.py +170 -0
- package/.next/standalone/fp-cloud-cli/tests/test_failproofai_home.py +722 -0
- package/.next/standalone/fp-cloud-cli/tests/test_fp_home_contract.py +198 -0
- package/.next/standalone/fp-cloud-cli/tests/test_hardening.py +186 -0
- package/.next/standalone/fp-cloud-cli/tests/test_help_table_coverage.py +127 -0
- package/.next/standalone/fp-cloud-cli/tests/test_keys_queries.py +496 -0
- package/.next/standalone/fp-cloud-cli/tests/test_list.py +59 -0
- package/.next/standalone/fp-cloud-cli/tests/test_multivalue.py +183 -0
- package/.next/standalone/fp-cloud-cli/tests/test_no_customer_identifiers.py +168 -0
- package/.next/standalone/fp-cloud-cli/tests/test_operator.py +492 -0
- package/.next/standalone/fp-cloud-cli/tests/test_orgs.py +692 -0
- package/.next/standalone/fp-cloud-cli/tests/test_output.py +1732 -0
- package/.next/standalone/fp-cloud-cli/tests/test_policy_check.py +191 -0
- package/.next/standalone/fp-cloud-cli/tests/test_readme_matches_reality.py +125 -0
- package/.next/standalone/fp-cloud-cli/tests/test_review_fixes.py +161 -0
- package/.next/standalone/fp-cloud-cli/tests/test_telemetry_completeness.py +90 -0
- package/.next/standalone/fp-cloud-cli/tests/test_usage.py +115 -0
- package/.next/standalone/fp-cloud-cli/tests/test_v1_origin_diagnostic.py +70 -0
- package/.next/standalone/fp-cloud-cli/tests/test_v1_routing.py +214 -0
- package/.next/standalone/fp-cloud-cli/tests/test_whoami.py +104 -0
- package/.next/standalone/fp-cloud-cli/uv.lock +552 -0
- package/.next/standalone/package.json +29 -22
- package/.next/standalone/sdk/python/CHANGELOG.md +70 -0
- package/.next/standalone/sdk/python/LICENSE +42 -0
- package/.next/standalone/sdk/python/MANIFEST.in +21 -0
- package/.next/standalone/sdk/python/README.md +466 -0
- package/.next/standalone/sdk/python/docs/README.md +249 -0
- package/.next/standalone/sdk/python/docs/_shared/README.md +43 -0
- package/.next/standalone/sdk/python/docs/_shared/__init__.py +195 -0
- package/.next/standalone/sdk/python/docs/crewai/README.md +328 -0
- package/.next/standalone/sdk/python/docs/crewai/examples/quickstart.py +61 -0
- package/.next/standalone/sdk/python/docs/crewai/examples/research_crew.py +92 -0
- package/.next/standalone/sdk/python/docs/langgraph/README.md +481 -0
- package/.next/standalone/sdk/python/docs/langgraph/examples/quickstart.py +51 -0
- package/.next/standalone/sdk/python/docs/langgraph/examples/research_agent.py +86 -0
- package/.next/standalone/sdk/python/docs/langgraph/examples/supervisor_handoff.py +83 -0
- package/.next/standalone/sdk/python/docs/llama_index/README.md +332 -0
- package/.next/standalone/sdk/python/docs/llama_index/examples/quickstart.py +62 -0
- package/.next/standalone/sdk/python/docs/llama_index/examples/research_agent.py +93 -0
- package/.next/standalone/sdk/python/docs/manual/README.md +491 -0
- package/.next/standalone/sdk/python/docs/manual/examples/quickstart.py +57 -0
- package/.next/standalone/sdk/python/docs/manual/examples/research_agent.py +133 -0
- package/.next/standalone/sdk/python/docs/pydantic_ai/README.md +323 -0
- package/.next/standalone/sdk/python/docs/pydantic_ai/examples/quickstart.py +48 -0
- package/.next/standalone/sdk/python/docs/pydantic_ai/examples/research_agent.py +94 -0
- package/.next/standalone/sdk/python/failproofai_sdk/__init__.py +131 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_context.py +218 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_environment.py +78 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_events.py +752 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_resolver.py +118 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_runtime.py +21 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_schema.py +317 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_scopes.py +422 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_writer.py +750 -0
- package/.next/standalone/sdk/python/failproofai_sdk/integrations/__init__.py +274 -0
- package/.next/standalone/sdk/python/failproofai_sdk/integrations/_compat.py +247 -0
- package/.next/standalone/sdk/python/failproofai_sdk/integrations/_core.py +1076 -0
- package/.next/standalone/sdk/python/failproofai_sdk/integrations/crewai.py +1669 -0
- package/.next/standalone/sdk/python/failproofai_sdk/integrations/langchain.py +2151 -0
- package/.next/standalone/sdk/python/failproofai_sdk/integrations/llama_index.py +1588 -0
- package/.next/standalone/sdk/python/failproofai_sdk/integrations/pydantic_ai.py +977 -0
- package/.next/standalone/sdk/python/failproofai_sdk/py.typed +0 -0
- package/.next/standalone/sdk/python/pyproject.toml +92 -0
- package/.next/standalone/sdk/python/skill/SKILL.md +399 -0
- package/.next/standalone/sdk/python/skill/agents/openai.yaml +8 -0
- package/.next/standalone/sdk/python/skill/references/events.md +234 -0
- package/.next/standalone/sdk/python/skill/references/frameworks.md +314 -0
- package/.next/standalone/sdk/python/skill/references/install.md +100 -0
- package/.next/standalone/sdk/python/skill/references/integration.md +352 -0
- package/.next/standalone/sdk/python/tests/__init__.py +0 -0
- package/.next/standalone/sdk/python/tests/conftest.py +169 -0
- package/.next/standalone/sdk/python/tests/integrations/__init__.py +6 -0
- package/.next/standalone/sdk/python/tests/integrations/test_crewai.py +1299 -0
- package/.next/standalone/sdk/python/tests/integrations/test_langchain.py +2338 -0
- package/.next/standalone/sdk/python/tests/integrations/test_llama_index.py +1576 -0
- package/.next/standalone/sdk/python/tests/integrations/test_pydantic_ai.py +1102 -0
- package/.next/standalone/sdk/python/tests/test_context.py +410 -0
- package/.next/standalone/sdk/python/tests/test_docs.py +411 -0
- package/.next/standalone/sdk/python/tests/test_durability.py +1352 -0
- package/.next/standalone/sdk/python/tests/test_encoding.py +571 -0
- package/.next/standalone/sdk/python/tests/test_integrations.py +1127 -0
- package/.next/standalone/sdk/python/tests/test_no_customer_identifiers.py +214 -0
- package/.next/standalone/sdk/python/tests/test_packaging.py +185 -0
- package/.next/standalone/sdk/python/tests/test_resolver_umbrella.py +178 -0
- package/.next/standalone/sdk/python/tests/test_scopes.py +487 -0
- package/.next/standalone/sdk/python/tests/test_sdk.py +923 -0
- package/.next/standalone/sdk/python/tests/test_server_contract.py +650 -0
- package/.next/standalone/sdk/python/tests/test_site_docs.py +371 -0
- package/.next/standalone/sdk/python/tests/test_skill_snippets.py +165 -0
- package/.next/standalone/sdk/python/tests/test_spool_contract.py +287 -0
- package/.next/standalone/sdk/python/tests/test_spool_creation.py +360 -0
- package/.next/standalone/sdk/python/tests/test_wire_format.py +279 -0
- package/.next/standalone/sdk/python/tests/test_zero_dependencies.py +378 -0
- package/.next/standalone/sdk/python/uv.lock +6005 -0
- package/.next/standalone/server.js +1 -1
- package/README.md +77 -21
- package/bin/failproofai.mjs +145 -11
- package/dist/cli.mjs +4482 -2205
- package/dist/index.js +5 -5
- package/dist/worker.mjs +2399 -1978
- package/package.json +29 -22
- package/policy-pack/SHA256SUMS +2 -0
- package/policy-pack/failproofai-pack.json +732 -0
- package/policy-pack/failproofai-pack.mjs +2017 -0
- package/scripts/build-policy-pack.mjs +121 -0
- package/scripts/changelog-section.py +77 -0
- package/scripts/prune-standalone.mjs +3 -0
- package/scripts/python-version.py +232 -0
- package/scripts/translate-docs/cli.ts +21 -0
- package/scripts/translate-docs/mintlify-nav.ts +85 -13
- package/scripts/translate-docs/translator.ts +66 -11
- package/src/audit/cache.ts +40 -3
- package/src/audit/cli.ts +1 -1
- package/src/audit/index.ts +1 -1
- package/src/audit/replay.ts +89 -3
- package/src/audit/report.ts +5 -5
- package/src/audit/schedule-cli.ts +49 -53
- package/src/hooks/builtin-policies.ts +217 -579
- package/src/hooks/cloud-enrollment-cli.ts +66 -27
- package/src/hooks/cloud-managed-policies.ts +6 -1
- package/src/hooks/configure-wizard.ts +1 -1
- package/src/hooks/custom-hooks-loader.ts +89 -6
- package/src/hooks/fp-home.ts +92 -4
- package/src/hooks/fp-reset.ts +26 -2
- package/src/hooks/handler.ts +181 -11
- package/src/hooks/harness-cli.ts +79 -25
- package/src/hooks/hook-activity-store.ts +13 -3
- package/src/hooks/install-prompt.ts +25 -3
- package/src/hooks/manager.ts +418 -172
- package/src/hooks/migrations.ts +2 -2
- package/src/hooks/pack-cli.ts +505 -0
- package/src/hooks/pack-failclosed.ts +189 -0
- package/src/hooks/pack-manifest.ts +344 -0
- package/src/hooks/pack-store.ts +930 -0
- package/src/hooks/policy-catalog.ts +535 -0
- package/src/hooks/policy-evaluator.ts +24 -16
- package/src/hooks/policy-presets.ts +4 -2
- package/src/hooks/policy-registry.ts +8 -2
- package/src/hooks/policy-types.ts +26 -0
- package/src/hooks/session-pause-cli.ts +33 -9
- package/src/hooks/tui.ts +570 -0
- package/src/hooks/types.ts +15 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_usx0j._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_zk340._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0agdw2a._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0d1u_na._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0oy4kxb._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zf034w._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tvn4wf._.js +0 -3
- package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +0 -3
- package/.next/standalone/.next/server/chunks/_1ger0a2._.js +0 -3
- package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +0 -3
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_1iiwm5e._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kc8_v5._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kgy9da._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mhqk5a._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pltpn8._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qkcyf-._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0w0x_ok._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1_dfocn._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1bfex62._.js +0 -5
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1kzapg5._.js +0 -6
- package/.next/standalone/.next/static/chunks/36fw6yk1s6x_f.js +0 -1
- package/.next/standalone/.next/static/chunks/3tzq0260n17ae.js +0 -6
- package/.next/standalone/.next/static/chunks/3wq9x0c-7ld5d.css +0 -2
- /package/.next/standalone/.next/static/{ZklXxyA5ygsWSQz2u_zii → nwbyWT06Ud0XdXOrVJyvS}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{ZklXxyA5ygsWSQz2u_zii → nwbyWT06Ud0XdXOrVJyvS}/_clientMiddlewareManifest.js +0 -0
- /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}"
|