Add test/hk_agent_fsm_tests.erl
Browse files- test/hk_agent_fsm_tests.erl +84 -0
test/hk_agent_fsm_tests.erl
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
%%%-------------------------------------------------------------------
|
| 2 |
+
%%% @doc Covers: deterministic state transitions, authorization
|
| 3 |
+
%%% boundaries, and loop-limit enforcement (the "DAG-like agent
|
| 4 |
+
%%% workflows", "authorization boundaries", and "deterministic state
|
| 5 |
+
%%% transitions" items from `<test>').
|
| 6 |
+
%%% @end
|
| 7 |
+
%%%-------------------------------------------------------------------
|
| 8 |
+
-module(hk_agent_fsm_tests).
|
| 9 |
+
-include_lib("eunit/include/eunit.hrl").
|
| 10 |
+
|
| 11 |
+
setup() -> hk_test_helper:start_app().
|
| 12 |
+
teardown(Ok) -> hk_test_helper:stop_app(Ok).
|
| 13 |
+
|
| 14 |
+
agent_fixture_test_() ->
|
| 15 |
+
{setup, fun setup/0, fun teardown/1, fun(_) -> [
|
| 16 |
+
{"created -> ready -> planning -> ... deterministic transitions",
|
| 17 |
+
fun deterministic_transitions/0},
|
| 18 |
+
{"unauthorized tool call is rejected without reaching the browser",
|
| 19 |
+
fun unauthorized_call_rejected/0},
|
| 20 |
+
{"invalid arguments are rejected at the control-plane boundary",
|
| 21 |
+
fun invalid_arguments_rejected/0},
|
| 22 |
+
{"exceeding max_actions transitions the agent to failed",
|
| 23 |
+
fun limit_exceeded_fails_agent/0},
|
| 24 |
+
{"agent process crash does not affect an unrelated agent",
|
| 25 |
+
fun crash_isolation/0}
|
| 26 |
+
] end}.
|
| 27 |
+
|
| 28 |
+
deterministic_transitions() ->
|
| 29 |
+
AgentId = hk_id:new(<<"agent">>),
|
| 30 |
+
{ok, Pid} = hk_agent_fsm_sup:start_agent(#{agent_id => AgentId, capabilities => [read_page]}),
|
| 31 |
+
{ok, #{state := created}} = as_map(hk_agent_fsm:describe(Pid)),
|
| 32 |
+
ok = hk_agent_fsm:assign_objective(Pid, <<"test objective">>),
|
| 33 |
+
{ok, #{state := ready}} = as_map(hk_agent_fsm:describe(Pid)),
|
| 34 |
+
%% A tool call the agent lacks any browser session for still drives
|
| 35 |
+
%% the state machine through planning -> executing -> waiting ->
|
| 36 |
+
%% planning; it fails at the toolbox level (no such session), which
|
| 37 |
+
%% is a normal, observed action outcome, not a stuck state.
|
| 38 |
+
{error, _} = hk_agent_fsm:next_action(Pid, browser, read_page,
|
| 39 |
+
#{session_id => <<"missing">>, tab_id => <<"missing">>}),
|
| 40 |
+
{ok, #{state := planning, action_count := 1}} = as_map(hk_agent_fsm:describe(Pid)),
|
| 41 |
+
ok = hk_agent_fsm:complete(Pid, #{summary => <<"done">>}),
|
| 42 |
+
{ok, #{state := completed}} = as_map(hk_agent_fsm:describe(Pid)).
|
| 43 |
+
|
| 44 |
+
unauthorized_call_rejected() ->
|
| 45 |
+
AgentId = hk_id:new(<<"agent">>),
|
| 46 |
+
{ok, Pid} = hk_agent_fsm_sup:start_agent(#{agent_id => AgentId, capabilities => [read_page]}),
|
| 47 |
+
ok = hk_agent_fsm:assign_objective(Pid, <<"obj">>),
|
| 48 |
+
Result = hk_agent_fsm:next_action(Pid, browser, click,
|
| 49 |
+
#{session_id => <<"s">>, tab_id => <<"t">>, selector => <<"#x">>}),
|
| 50 |
+
?assertEqual({error, not_authorized}, Result),
|
| 51 |
+
%% Action count must not increase for a call that never reached the toolbox.
|
| 52 |
+
{ok, #{action_count := 0}} = as_map(hk_agent_fsm:describe(Pid)).
|
| 53 |
+
|
| 54 |
+
invalid_arguments_rejected() ->
|
| 55 |
+
AgentId = hk_id:new(<<"agent">>),
|
| 56 |
+
{ok, Pid} = hk_agent_fsm_sup:start_agent(#{agent_id => AgentId, capabilities => [navigate]}),
|
| 57 |
+
ok = hk_agent_fsm:assign_objective(Pid, <<"obj">>),
|
| 58 |
+
Result = hk_agent_fsm:next_action(Pid, browser, navigate,
|
| 59 |
+
#{session_id => <<"s">>, tab_id => <<"t">>,
|
| 60 |
+
url => <<"javascript:evil()">>}),
|
| 61 |
+
?assertMatch({error, {invalid_arguments, _}}, Result).
|
| 62 |
+
|
| 63 |
+
limit_exceeded_fails_agent() ->
|
| 64 |
+
AgentId = hk_id:new(<<"agent">>),
|
| 65 |
+
{ok, Pid} = hk_agent_fsm_sup:start_agent(#{agent_id => AgentId, capabilities => [read_page],
|
| 66 |
+
limits => #{max_actions => 0}}),
|
| 67 |
+
ok = hk_agent_fsm:assign_objective(Pid, <<"obj">>),
|
| 68 |
+
Result = hk_agent_fsm:next_action(Pid, browser, read_page, #{session_id => <<"s">>, tab_id => <<"t">>}),
|
| 69 |
+
?assertMatch({error, {limit_exceeded, max_actions}}, Result),
|
| 70 |
+
{ok, #{state := failed, failure_reason := Reason}} = as_map(hk_agent_fsm:describe(Pid)),
|
| 71 |
+
?assertMatch({limit_exceeded, max_actions}, Reason).
|
| 72 |
+
|
| 73 |
+
crash_isolation() ->
|
| 74 |
+
{ok, PidA} = hk_agent_fsm_sup:start_agent(#{agent_id => hk_id:new(<<"agent">>), capabilities => []}),
|
| 75 |
+
{ok, PidB} = hk_agent_fsm_sup:start_agent(#{agent_id => hk_id:new(<<"agent">>), capabilities => []}),
|
| 76 |
+
Ref = monitor(process, PidA),
|
| 77 |
+
exit(PidA, kill),
|
| 78 |
+
receive {'DOWN', Ref, process, PidA, killed} -> ok after 2000 -> ?assert(false) end,
|
| 79 |
+
timer:sleep(100),
|
| 80 |
+
?assert(is_process_alive(PidB)),
|
| 81 |
+
?assert(is_process_alive(whereis(hk_agent_sup))),
|
| 82 |
+
?assert(is_process_alive(whereis(hyperkitty_sup))).
|
| 83 |
+
|
| 84 |
+
as_map({ok, Record}) -> {ok, hk_schema:to_map(Record)}.
|