180 lines
11 KiB
Python
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()
|