LazyBoy2/tests/team_flow.py

175 lines
12 KiB
Python
Raw Normal View History

2026-09-13 16:38:32 +00:00
"""Local daemon + two CLI clients + mock provider. No paid model calls."""
import json, os, signal, socket, sqlite3, subprocess, tempfile, threading, time
from pathlib import Path
from http.server import ThreadingHTTPServer
from runtime_flow import Provider
from cli_flow import BINARY, tool, response
def wait(fn, seconds=15):
end=time.monotonic()+seconds
while time.monotonic()<end:
try:value=fn()
except (ConnectionRefusedError,FileNotFoundError):value=None
if value:return value
time.sleep(.05)
raise AssertionError('timed out')
def main():
with tempfile.TemporaryDirectory(prefix='gb-team-',dir='/tmp') as root, ThreadingHTTPServer(('127.0.0.1',0),Provider) as server:
root=Path(root);data=root/'data';workspace=root/'work';workspace.mkdir();server.errors=[]
def answer(text):return response({'role':'assistant','content':text})
def callback(req):
msgs=req['messages'];system=msgs[0]['content'];last=msgs[-1]
if system.startswith('Extract memory'):
payload=json.loads(msgs[-1]['content'])
expertise='research analysis' if 'teach research' in payload['conversation'] else payload['previous_expertise']
return answer(json.dumps({'expertise':expertise,'memories':[{'kind':'user_statement','content':'private example note','supersedes':''}]}))
if 'persistent GrokBoy main agent' in system:
user=next(m['content'] for m in reversed(msgs) if m['role']=='user')
if 'Background task result' in user:return answer('Background report received and assessed.')
if user=='teach research':return answer('Learned your research context.')
if user=='2':
if last['role']=='user':
pending=next(t for t in rpc('tasks','a') if t['state']=='waiting_input')
return response(tool('answer_task',{'task_id':pending['id']}))
assert json.loads(last['content']).get('forwarded'),last
return answer('Forwarded your answer to the task.')
if user=='ping':return answer('pong while background work continues')
if user.startswith('start '):
goal=user.removeprefix('start ')
if last['role']=='user':return response(tool('find_agents',{'query':'research'}))
if last['role']=='tool':
data=json.loads(last['content'])
if isinstance(data,list):return response(tool('delegate_task',{'target':data[0]['id'],'goal':goal}))
return answer('Delegated; still available to chat.')
return answer('hello')
goal=next(m['content'] for m in msgs if m['role']=='user');results=[json.loads(m['content']) for m in msgs if m['role']=='tool']
if goal=='ROOT_TASK':
if not results:return response(tool('search_memory',{'query':'private'}))
if len(results)==1:
assert results[0] and results[0][0]['content']=='private example note',results
return response(tool('spawn_agent',{'goal':'CHILD_TASK'}))
child=results[1]['id']
if len(results)==2:return response(tool('wait_task',{'task_id':child,'timeout_ms':20000}))
assert any('done' in json.dumps(x) for x in results[2:]),results
return response(tool('report_done',{'message':'Verified child artifact result.txt.'}))
if goal=='CHILD_TASK':
if not results:return response(tool('exec_command',{'cmd':'sleep 1; printf artifact > result.txt','yield_time_ms':10000}))
if len(results)==1:return response(tool('read_file',{'path':'result.txt'}))
assert results[-1]['content']=='artifact',results
return response(tool('report_done',{'message':'Read result.txt and verified artifact.'}))
if goal=='QUESTION':
if not results:return response(tool('request_user_input',{'question':'Choose a label','options':['alpha','beta']}))
assert results[-1].get('answer')=='beta' or any('beta' in m.get('content','') and 'unanswered question' in m.get('content','') for m in msgs),results
return response(tool('report_done',{'message':'selected beta'}))
if goal=='STEER':
if not results:
first=tool('exec_command',{'cmd':'echo started > steer.started; sleep 0.8','yield_time_ms':10000})
second=tool('write_file',{'path':'forbidden.txt','content':'bad'})['tool_calls'][0];second['id']='second';first['tool_calls'].append(second)
return response(first)
assert not (workspace/'forbidden.txt').exists()
assert any('do not write forbidden' in m.get('content','') for m in msgs)
return response(tool('report_done',{'message':'steering applied; no forbidden file'}))
if goal in ('BROWSER_SET','BROWSER_CHECK','BROWSER_OTHER'):
if not results:return response(tool('browser_navigate',{'url':f'http://127.0.0.1:{server.server_port}/fixture'}))
if len(results)==1:
expression="document.cookie='private_cookie=one'; localStorage.setItem('private','one'); 'set'" if goal=='BROWSER_SET' else "({cookie:document.cookie,storage:localStorage.getItem('private')})"
return response(tool('browser_eval',{'expression':expression}))
assert 'error' not in results[-1],results
if goal=='BROWSER_SET' and len(results)==2:return response(tool('browser_release',{}))
if goal=='BROWSER_CHECK':assert results[-1]['result']=={'cookie':'private_cookie=one','storage':'one'},results[-1]
if goal=='BROWSER_OTHER':assert results[-1]['result']=={'cookie':'','storage':None},results[-1]
return response(tool('report_done',{'message':'browser profile verified'}))
if goal=='LONG':
if not results:return response(tool('exec_command',{'cmd':'echo $$ > running.pid; sleep 60','yield_time_ms':10000}))
return response(tool('report_done',{'message':'should not finish'}))
raise AssertionError((goal,results))
server.callback=callback;threading.Thread(target=server.serve_forever,daemon=True).start()
env={**os.environ,'GROKBOY_DATA_DIR':str(data),'GROKBOY_API_KEY':'mock','GROKBOY_MODEL':'mock','GROKBOY_BASE_URL':f'http://127.0.0.1:{server.server_port}/v1','GROKBOY_MAX_ROUNDS_TOTAL':'48','GROKBOY_BROWSER_HEADED':'0'}
logs=[];clients=[];daemons=[]
def start():
p=subprocess.Popen([str(BINARY),'serve'],cwd=workspace,env=env,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
daemons.append(p)
threading.Thread(target=lambda:[logs.append(l) for l in p.stderr],daemon=True).start()
wait(lambda:(data/'service.sock').exists() and p.poll() is None)
wait(lambda:rpc('ping'))
return p
def rpc(op,agent=None,**kw):
with socket.socket(socket.AF_UNIX) as s:
s.settimeout(5);s.connect(str(data/'service.sock'));s.sendall((json.dumps({'op':op,'agent':agent,**kw})+'\n').encode());f=s.makefile();v=json.loads(f.readline())
assert 'error' not in v,v
return v
def tasks():return rpc('tasks','a')
def latest(goal):return next((t for t in reversed(tasks()) if t['goal']==goal),None)
def terminal(goal):
t=latest(goal);return t if t and t['state']=='terminal' else None
def event_reply(agent,text):return any(text in e['payload'].get('message','') for e in rpc('events',agent,after=0)['events'] if e['kind']=='reply')
def client(name):
p=subprocess.Popen([str(BINARY),'agent','--name',name],cwd=workspace,env=env,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
out=[];threading.Thread(target=lambda:[out.append(l) for l in p.stdout],daemon=True).start();threading.Thread(target=lambda:[logs.append(l) for l in p.stderr],daemon=True).start();clients.append(p);return p,out
def send(p,text):p.stdin.write(text+'\n');p.stdin.flush()
daemon=None
try:
daemon=start()
for name in ['a','b']:rpc('create',name=name,cwd=str(workspace))
a,aout=client('a');b,bout=client('b')
send(b,'teach research');wait(lambda:rpc('expertise','b')['expertise']=='research analysis')
assert rpc('memory','a')==[]
assert 'private example note' not in json.dumps(rpc('agents'))
print('PASS automatic expertise extraction and private memory isolation',flush=True)
send(a,'start ROOT_TASK');wait(lambda:latest('CHILD_TASK'))
send(a,'ping');send(b,'ping')
wait(lambda:any('pong' in x for x in aout));wait(lambda:any('pong' in x for x in bout))
root_task=wait(lambda:terminal('ROOT_TASK'))
assert root_task['verdict']=='done',root_task
assert (workspace/'result.txt').read_text()=='artifact'
wait(lambda:any('Background report' in x for x in aout))
print('PASS expertise routing, nested delegation, verified artifact and two live chats',flush=True)
send(a,'start QUESTION');wait(lambda:latest('QUESTION') and latest('QUESTION')['state']=='waiting_input')
q=latest('QUESTION');send(a,'/exit');a.wait(timeout=5)
assert latest('QUESTION')['state']=='waiting_input'
send(b,'ping');wait(lambda:event_reply('b','pong'))
rpc('chat','a',message='2')
assert wait(lambda:terminal('QUESTION'))['verdict']=='done'
a,aout=client('a');wait(lambda:any('Background report' in x for x in aout))
print('PASS durable question, CLI disconnect and reconnect notification',flush=True)
send(a,'start LONG');wait(lambda:(workspace/'running.pid').exists());long=latest('LONG')
rpc('stop','a',task=long['id']);wait(lambda:terminal('LONG'))
pid=int((workspace/'running.pid').read_text())
def dead():
try:os.kill(pid,0);return False
except ProcessLookupError:return True
wait(dead)
send(b,'ping');wait(lambda:event_reply('b','pong'))
print('PASS task cancellation kills process group without stopping peer chat',flush=True)
send(a,'start STEER');wait(lambda:(workspace/'steer.started').exists())
steering=latest('STEER');rpc('say','a',task=steering['id'],message='do not write forbidden.txt')
assert wait(lambda:terminal('STEER'))['verdict']=='done'
assert not (workspace/'forbidden.txt').exists()
print('PASS task-specific steering skips unstarted actions',flush=True)
for goal in ('BROWSER_SET','BROWSER_CHECK'):
send(a,'start '+goal);result=wait(lambda:terminal(goal),30);assert result['verdict']=='done',result
send(b,'start BROWSER_OTHER');assert wait(lambda:next((t for t in rpc('tasks','b') if t['goal']=='BROWSER_OTHER' and t['state']=='terminal'),None),30)['verdict']=='done'
profiles=list((data/'profiles').glob('owner-*.browser'));assert len(profiles)==2,profiles
print('PASS real Chromium login persists across tasks; different owners remain isolated',flush=True)
send(a,'start QUESTION');wait(lambda:latest('QUESTION')['state']=='waiting_input')
q=latest('QUESTION');daemon.kill();daemon.wait(timeout=5)
daemon=start();t=latest('QUESTION');assert t['verdict']=='interrupted' and t['question'],t
rpc('resume','a',task=q['id']);wait(lambda:latest('QUESTION')['state']=='waiting_input')
rpc('say','a',task=q['id'],message='2')
assert wait(lambda:terminal('QUESTION'))['verdict']=='done'
print('PASS daemon crash, unknown-state recovery and explicit resume',flush=True)
db=sqlite3.connect(data/'team.sqlite3');
for (raw,) in db.execute('select data from tasks'):
t=json.loads(raw);assert t['requests']<=t['limit']
assert not server.errors,server.errors
except Exception:
print('daemon/client logs:\n'+''.join(logs));raise
finally:
for p in clients:
if p.poll() is None:p.terminate()
for p in daemons:
if p.poll() is None:p.send_signal(signal.SIGINT);p.wait(timeout=10)
server.shutdown()
if __name__=='__main__':main()