LazyBoy2/tests/runtime_flow.py

180 lines
11 KiB
Python

"""CLI integration for plan/progress, steering, questions, cancellation and long jobs."""
import json
import os
from pathlib import Path
import queue
import signal
import subprocess
import tempfile
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from cli_flow import BINARY, tool, response, paired
class Provider(BaseHTTPRequestHandler):
def do_POST(self):
request=json.loads(self.rfile.read(int(self.headers['Content-Length'])))
try:
paired(request['messages'])
reply=self.server.callback(request)
except Exception as error:
self.server.errors.append(repr(error))
reply={'error':{'message':repr(error)}}
body=json.dumps(reply).encode()
self.send_response(200); self.send_header('Content-Type','application/json')
self.send_header('Content-Length',str(len(body))); self.end_headers()
try:self.wfile.write(body)
except (BrokenPipeError,ConnectionResetError):pass
def do_GET(self):
body=b"<title>Local fixture</title><body>browser checkpoint evidence</body>"
self.send_response(200);self.send_header("Content-Length",str(len(body)));self.end_headers();self.wfile.write(body)
def log_message(self,*_):pass
class Run:
def __init__(self,root,server,args=None):
self.lines=[]; self.output=[]; self.events=queue.Queue()
self.process=subprocess.Popen([str(BINARY),*(args or ['run','complete the original task'])],cwd=root,
env={**os.environ,'GROKBOY_API_KEY':'offline','GROKBOY_BASE_URL':f'http://127.0.0.1:{server.server_port}/v1',
'GROKBOY_MODEL':'mock','GROKBOY_SESSIONS_DIR':str(Path(root)/'sessions'),
'GROKBOY_MAX_ROUNDS_TOTAL':'20','GROKBOY_MAX_ROUNDS':'2','GROKBOY_PROGRESS':'1',
'GROKBOY_CONTEXT_CHARS':'100000','GROKBOY_CONFIRM_AUTO':'','GROKBOY_HANDOFF_AUTO':'',
'GROKBOY_BROWSER_HEADED':'0'},
stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
def read_err():
for line in self.process.stderr:self.lines.append(line); self.events.put(line)
threading.Thread(target=read_err,daemon=True).start()
threading.Thread(target=lambda:[self.output.append(line) for line in self.process.stdout],daemon=True).start()
def send(self,text):self.process.stdin.write(text+'\n');self.process.stdin.flush()
def wait_line(self,text,timeout=10):
deadline=time.monotonic()+timeout
while time.monotonic()<deadline:
line=self.events.get(timeout=max(.01,deadline-time.monotonic()))
if text in line:return
raise AssertionError((text,self.lines))
def finish(self,code=0,timeout=15):
assert self.process.wait(timeout=timeout)==code,''.join(self.lines)
self.process.stdin.close()
return ''.join(self.lines)
def wait_file(path,timeout=10):
deadline=time.monotonic()+timeout
while not path.exists() and time.monotonic()<deadline:time.sleep(.02)
assert path.exists(),path
def saved(root):
paths=list(Path(root,'sessions').glob('*.json'))
value=json.loads(max(paths,key=lambda p:p.stat().st_mtime_ns).read_text())
paired(value['messages'])
return value
def main():
with tempfile.TemporaryDirectory(prefix='grokboy-runtime-') as root,ThreadingHTTPServer(('127.0.0.1',0),Provider) as server:
server.errors=[]
threading.Thread(target=server.serve_forever,daemon=True).start()
runs=[]
try:
# Commentary before first side effect; plan advances only after observed output.
plan=[{'step':'create artifact','status':'in_progress'},{'step':'verify artifact','status':'pending'}]
replies=[response(tool('update_plan',{'plan':plan})),response(tool('write_file',{'path':'note.txt','content':'verified'})),
response(tool('read_file',{'path':'note.txt'})),response(tool('update_plan',{'plan':[{**s,'status':'completed'} for s in plan]})),
response(tool('report_done',{'message':'verified note.txt'}))]
replies[0]['choices'][0]['message']['content']='I will create the artifact and verify it.'
server.callback=lambda _:replies.pop(0)
run=Run(root,server);runs.append(run);log=run.finish()
assert log.index('I will create')<log.index('〔工具〕'),log
assert saved(root)['plan'][-1]['status']=='completed'
print('PASS commentary ordering and persistent plan lifecycle',flush=True)
# Steering while a command is actually in flight skips the following action.
count=[0]
def steer(request):
count[0]+=1
if count[0]==1:
first=tool('exec_command',{'cmd':'echo started > started; sleep 0.6','yield_time_ms':1000})
second=tool('write_file',{'path':'forbidden.txt','content':'bad'})['tool_calls'][0]
second['id']='second';first['tool_calls'].append(second)
return response(first)
assert any(m.get('content')=='do not write forbidden.txt' for m in request['messages'])
assert any(m.get('content')=='complete the original task' for m in request['messages'])
return response({'role':'assistant','content':'adapted to your constraint'})
server.callback=steer
run=Run(root,server);runs.append(run);wait_file(Path(root,'started'));run.send('do not write forbidden.txt');run.finish()
assert not Path(root,'forbidden.txt').exists();saved(root)
print('PASS steering during execution skips unstarted actions',flush=True)
replies=[response(tool('request_user_input',{'question':'Which label?','options':['alpha','beta']})),
response(tool('report_done',{'message':'selected beta'}))]
def question(request):
if len(replies)==1:assert any('beta' in m.get('content','') for m in request['messages'] if m['role']=='tool')
return replies.pop(0)
server.callback=question
run=Run(root,server);runs.append(run);run.wait_line('需要你的回覆');run.send('2');run.finish();assert saved(root)['pending_question'] is None
print('PASS user question routes answer to the waiting tool',flush=True)
# Cancellation of a model HTTP wait persists a resumable turn and exits 130.
started=threading.Event();release=threading.Event()
def slow(_):started.set();release.wait(10);return response({'role':'assistant','content':'late'})
server.callback=slow
run=Run(root,server);runs.append(run);assert started.wait(5);run.process.send_signal(signal.SIGINT);run.finish(130,5);release.set()
assert saved(root)['last_verdict']=='cancelled'
print('PASS Ctrl-C during model request saves cancelled state',flush=True)
# Cancellation of a live process group must stop its descendants too.
server.callback=lambda _:response(tool('exec_command',{'cmd':'echo $$ > child-pid; sleep 30','yield_time_ms':10000}))
run=Run(root,server);runs.append(run);wait_file(Path(root,'child-pid'));pid=int(Path(root,'child-pid').read_text())
run.process.send_signal(signal.SIGINT);run.finish(130,5);assert saved(root)['last_verdict']=='cancelled'
try:os.kill(pid,0)
except ProcessLookupError:pass
else:raise AssertionError('command survived cancellation')
print('PASS Ctrl-C during a long command stops process group',flush=True)
# Cancellation leaves the unanswered question for resumed interaction.
server.callback=lambda _:response(tool('request_user_input',{'question':'Need a value'}))
run=Run(root,server);runs.append(run);run.wait_line('需要你的回覆');run.process.send_signal(signal.SIGINT);run.finish(130,5)
pending=saved(root);assert pending['pending_question']
server.callback=lambda _:response(tool('report_done',{'message':'resumed after answer'}))
run=Run(root,server,['run','--session',pending['id'],'continue']);runs.append(run);run.wait_line('需要你的回覆');run.send('value');run.finish()
print('PASS cancelled question is asked again on resume',flush=True)
# An unanswered approval can be cancelled without leaving a second stdin reader.
server.callback=lambda _:response(tool('request_user_confirm',{'reason':'Approve test action','prompt':'Test draft'}))
run=Run(root,server);runs.append(run);run.wait_line('需要你的回覆');run.process.send_signal(signal.SIGINT);run.finish(130,5)
assert saved(root)['pending_question']['kind']=='confirm'
print('PASS Ctrl-C while waiting for confirmation',flush=True)
# Cancel a real browser wait, then restart the helper and inspect the saved URL.
replies=[response(tool('browser_navigate',{'url':f'http://127.0.0.1:{server.server_port}/fixture'})),
response(tool('browser_wait',{'selector':'#never','timeout_ms':30000}))]
server.callback=lambda _:replies.pop(0)
run=Run(root,server);runs.append(run);run.wait_line('round 2: browser_wait');time.sleep(.2)
run.process.send_signal(signal.SIGINT);run.finish(130,5);browser_session=saved(root)
replies=[response(tool('browser_read_page',{})),response(tool('report_done',{'message':'browser resumed'}))]
def resumed_browser(request):
if len(replies)==1:assert any('browser checkpoint evidence' in m.get('content','') for m in request['messages'] if m['role']=='tool')
return replies.pop(0)
server.callback=resumed_browser
run=Run(root,server,['run','--session',browser_session['id'],'continue']);runs.append(run);run.finish()
print('PASS Ctrl-C during browser wait and saved URL/profile resume',flush=True)
# Real 31-second command, polling through multiple identical no-output waits.
count=[0]
def long_job(request):
count[0]+=1
if count[0]==1:return response(tool('exec_command',{'cmd':'sleep 31; printf long-ok','yield_time_ms':1000}))
last=next(json.loads(m['content']) for m in reversed(request['messages']) if m['role']=='tool')
if last.get('running'):return response(tool('write_stdin',{'session_id':last['session_id'],'yield_time_ms':10000}))
assert last['exit_code']==0 and last['stdout']=='long-ok',last
return response(tool('report_done',{'message':'long command verified'}))
server.callback=long_job
run=Run(root,server);runs.append(run);run.finish(timeout=40)
assert saved(root)['last_verdict']=='done'
print('PASS >30 second command and controlled polling',flush=True)
assert not server.errors,server.errors
finally:
for run in runs:
if run.process.poll() is None:run.process.send_signal(signal.SIGINT)
server.shutdown()
if __name__=='__main__':main()