"""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 ThreadingHTTPServer from cli_flow import BINARY, tool, response, paired, Provider as StreamingProvider class Provider(StreamingProvider): def do_POST(self): if not hasattr(self.server, 'requests'): self.server.requests = [] try: super().do_POST() 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('正在做 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()