"""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"
Local fixturebrowser checkpoint evidence"
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','GROKBOY_BROWSER_SURFACE':'local'},
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() started; sleep 0.6','yield_time_ms':1000})
second=tool('external_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)
server.callback=lambda _:response(tool('request_user_input',{'question':'Which label?','options':['alpha','beta']}))
run=Run(root,server);runs.append(run);run.wait_line('需要你的回覆');run.finish()
pending=saved(root);assert pending['last_verdict']=='waiting' and pending['pending_question']
server.callback=lambda _:response(tool('report_done',{'message':'selected beta'}))
run=Run(root,server,['run','--session',pending['id'],'2']);runs.append(run);run.finish()
assert saved(root)['pending_question'] is None
print('PASS user question parks and next message is the answer',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('external_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.finish()
pending=saved(root);assert pending['pending_question'] and pending['last_verdict']=='waiting'
server.callback=lambda _:response(tool('report_done',{'message':'resumed after answer'}))
run=Run(root,server,['run','--session',pending['id'],'value']);runs.append(run);run.finish()
print('PASS parked question resumes with the next user line as the answer',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.finish()
assert saved(root)['pending_question']['kind']=='confirm'
assert saved(root)['last_verdict']=='waiting'
print('PASS confirm parks instead of blocking the turn',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('external_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('external_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()