"""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()