"""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'}, 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('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()