Remote brain: a computer that just came on is known at once; an offline computer is said, not swapped; snapshots survive an open browser; end-to-end test
- The brain looked the account's computers up once a minute: for up to a minute after the Mac came on, 「我的電腦」 was unknown and steps went elsewhere. Now every 15 s, and at once when a message comes from a computer it doesn't know yet. - A step naming a computer that is off says so in words instead of running on the chosen one with a note that the person hadn't asked. - Snapshots: tar's 'file changed' (the open browser) is not a failure; browser caches are left out; ten minutes for a large home folder. - box_end_to_end clicks the terminal where it opened; restore only with ANYBOT_TEST_RESTORE=1. - deploy/test/remote_e2e.py: account server + headless brain + hands + scripted model, 27 checks (computers, redirect, agent-desktop on the hands, hand-off 3 ways, phone view, sync copy, edits, hands offline/back, brain restart, clear). anybot --sample-state for such tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
46c0684cb4
commit
06801f549d
|
|
@ -0,0 +1,352 @@
|
||||||
|
# End-to-end test of a brain on a server working on a person's computer, all on this machine and
|
||||||
|
# all made up for the test: an account server, a headless brain, a headless "hands" computer (it
|
||||||
|
# also plays the person's screen, as a Mac showing the brain does), and a scripted fake model.
|
||||||
|
# Never touches a real account, and no Docker box (the bot's own computer is a sealed folder).
|
||||||
|
# Build first (cargo build --release; and in relay/), then, from app/:
|
||||||
|
# python3 -I deploy/test/remote_e2e.py .
|
||||||
|
# On a Mac the terminal needs 輔助使用 (the hands open Calculator through agent-desktop).
|
||||||
|
import json, os, shutil, socket, subprocess, sys, tempfile, threading, time, urllib.request, urllib.error
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
APP = os.path.abspath(sys.argv[1])
|
||||||
|
BIN = os.path.join(APP, 'target/release/anybot')
|
||||||
|
RELAY = os.path.join(APP, 'relay/target/release/anybot-relay')
|
||||||
|
WORK = tempfile.mkdtemp(prefix='anybot-e2e-')
|
||||||
|
USER, PW = 'e2euser', 'e2e-pass-12345'
|
||||||
|
HANDS = 'E2E手腳'
|
||||||
|
results = []
|
||||||
|
|
||||||
|
def free_port():
|
||||||
|
s = socket.socket(); s.bind(('127.0.0.1', 0)); p = s.getsockname()[1]; s.close(); return p
|
||||||
|
|
||||||
|
def check(name, ok, detail=''):
|
||||||
|
results.append((name, bool(ok), detail))
|
||||||
|
print(('PASS ' if ok else 'FAIL ') + name + ('' if ok else f' — {detail}'), flush=True)
|
||||||
|
|
||||||
|
# ---------- the scripted model ----------
|
||||||
|
def tool_call(name, args):
|
||||||
|
return {'tool': (name, json.dumps(args, ensure_ascii=False))}
|
||||||
|
|
||||||
|
SCRIPTS = {
|
||||||
|
'SC_LIST': [tool_call('list_computers', {})],
|
||||||
|
'SC_OPEN': [tool_call('computer_use_open_app', {'app': 'Calculator', 'computer': HANDS})],
|
||||||
|
'SC_SHELL': [tool_call('shell', {'command': 'echo e2e-$((6*7))', 'computer': HANDS})],
|
||||||
|
'SC_REDIRECT': [tool_call('shell', {'command': 'echo moved-here', 'computer': HANDS})],
|
||||||
|
'SC_HANDOFF': [tool_call('hand_over_computer', {'instruction': 'E2E:請按「我完成了」', 'computer': HANDS})],
|
||||||
|
'SC_OWN': [tool_call('shell', {'command': 'echo on-own-$((2+3))'})],
|
||||||
|
}
|
||||||
|
|
||||||
|
class Model(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
def do_GET(self):
|
||||||
|
body = json.dumps({'data': [{'id': 'e2e'}]}).encode()
|
||||||
|
self.send_response(200); self.send_header('Content-Type', 'application/json'); self.send_header('Content-Length', str(len(body))); self.end_headers(); self.wfile.write(body)
|
||||||
|
def do_POST(self):
|
||||||
|
n = int(self.headers.get('Content-Length', '0') or 0)
|
||||||
|
req = json.loads(self.rfile.read(n) or b'{}')
|
||||||
|
msgs = req.get('messages', [])
|
||||||
|
last_user = max((i for i, m in enumerate(msgs) if m.get('role') == 'user'), default=-1)
|
||||||
|
text = msgs[last_user].get('content', '') if last_user >= 0 else ''
|
||||||
|
if isinstance(text, list):
|
||||||
|
text = ' '.join(p.get('text', '') for p in text if isinstance(p, dict))
|
||||||
|
done = sum(1 for m in msgs[last_user + 1:] if m.get('role') == 'tool')
|
||||||
|
last_tool = next((m.get('content', '') for m in reversed(msgs) if m.get('role') == 'tool'), '')
|
||||||
|
script = next((v for k, v in SCRIPTS.items() if k in text), [])
|
||||||
|
if done < len(script):
|
||||||
|
name, args = script[done]['tool']
|
||||||
|
chunk = {'choices': [{'delta': {'tool_calls': [{'index': 0, 'id': f'c{int(time.time()*1000)}', 'function': {'name': name, 'arguments': args}}]}}]}
|
||||||
|
else:
|
||||||
|
chunk = {'choices': [{'delta': {'content': 'E2E 完成:' + (last_tool if isinstance(last_tool, str) else '')[:300]}}]}
|
||||||
|
self.send_response(200); self.send_header('Content-Type', 'text/event-stream'); self.end_headers()
|
||||||
|
self.wfile.write(('data: ' + json.dumps(chunk, ensure_ascii=False) + '\n\ndata: [DONE]\n\n').encode())
|
||||||
|
|
||||||
|
# ---------- relay helpers ----------
|
||||||
|
RP = free_port(); MP = free_port()
|
||||||
|
RURL = f'http://127.0.0.1:{RP}'
|
||||||
|
|
||||||
|
def http(method, url, body=None, token=None, timeout=60, raw=False):
|
||||||
|
data = None if body is None else json.dumps(body).encode()
|
||||||
|
req = urllib.request.Request(url, method=method, data=data, headers={'Content-Type': 'application/json', **({'Authorization': 'Bearer ' + token} if token else {})})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
b = r.read(); return r.status, (b if raw else json.loads(b or b'null'))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
b = e.read()
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(b)
|
||||||
|
except Exception:
|
||||||
|
return e.code, b
|
||||||
|
|
||||||
|
procs = {}
|
||||||
|
def start(name, args, env):
|
||||||
|
log = open(os.path.join(WORK, name + '.log'), 'a')
|
||||||
|
e = dict(os.environ); e.update(env)
|
||||||
|
procs[name] = subprocess.Popen(args, env=e, stdout=log, stderr=log, cwd=WORK)
|
||||||
|
|
||||||
|
def stop(name):
|
||||||
|
p = procs.pop(name, None)
|
||||||
|
if p:
|
||||||
|
p.terminate()
|
||||||
|
try:
|
||||||
|
p.wait(10)
|
||||||
|
except Exception:
|
||||||
|
p.kill()
|
||||||
|
|
||||||
|
def template():
|
||||||
|
path = os.path.join(WORK, 'sample.json')
|
||||||
|
subprocess.run([BIN, '--sample-state', path], check=True)
|
||||||
|
t = json.load(open(path))
|
||||||
|
for k in ('channels', 'mcp_servers', 'skills', 'pages', 'review_allow_rules', 'review_ask_rules', 'remote_computers', 'folders'):
|
||||||
|
if k in t:
|
||||||
|
t[k] = []
|
||||||
|
t['providers'] = {'custom': {'api_key': '', 'base_url': f'http://127.0.0.1:{MP}/v1', 'default_model': 'e2e', 'known_models': ['e2e']}}
|
||||||
|
t['remote'] = {'lan': False, 'password': '', 'devices': [], 'mac_from_phone': False, 'device_link': False, 'relay_url': '', 'relay_user': '', 'host_id': '', 'host_token': '', 'host_name': '', 'backup_key': '', 'auto_backup': False, 'last_backup': 0}
|
||||||
|
b = t['bots'][0]
|
||||||
|
b.update({'name': 'Master', 'provider': 'Custom', 'model': 'e2e', 'messages': [], 'memories': [], 'allow_rules': [], 'last_turn': None, 'default_computer': 'Bot 的電腦', 'tools_enabled': True, 'instructions': ''})
|
||||||
|
b.pop('threads', None)
|
||||||
|
t['bots'] = [b]; t['primary_bot'] = b['id']; t['selected'] = b['id']
|
||||||
|
t['local_execution'] = False; t['direct_mode'] = False; t['auto_review'] = False
|
||||||
|
return t
|
||||||
|
|
||||||
|
NOBOX = {'DOCKER_HOST': 'unix:///nonexistent/docker.sock', 'PATH': '/usr/bin:/bin:/usr/sbin:/sbin'}
|
||||||
|
|
||||||
|
def run():
|
||||||
|
srv = ThreadingHTTPServer(('127.0.0.1', MP), Model)
|
||||||
|
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||||
|
os.makedirs(os.path.join(WORK, 'relay'))
|
||||||
|
start('relay', [RELAY, '--bind', '127.0.0.1', '--port', str(RP), '--data', os.path.join(WORK, 'relay')], {})
|
||||||
|
for _ in range(50):
|
||||||
|
try:
|
||||||
|
if http('GET', RURL + '/api/hello')[0] == 200:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
time.sleep(0.2)
|
||||||
|
# The brain: a seeded bot on the fake model, joins (and makes) the account.
|
||||||
|
bdir = os.path.join(WORK, 'brain'); os.makedirs(bdir)
|
||||||
|
st = template(); st['remote']['host_name'] = 'E2E大腦'
|
||||||
|
json.dump(st, open(os.path.join(bdir, 'state.json'), 'w'), ensure_ascii=False)
|
||||||
|
benv = {'ANYBOT_DATA_DIR': bdir, 'ANYBOT_ACCOUNT': f'{RURL}|{USER}', 'ANYBOT_ACCOUNT_PASSWORD': PW, 'ANYBOT_ACCOUNT_CREATE': '1', 'ANYBOT_HOST_NAME': 'E2E大腦', **NOBOX}
|
||||||
|
start('brain', [BIN, '--serve'], benv)
|
||||||
|
brain_id = None
|
||||||
|
for _ in range(60):
|
||||||
|
time.sleep(0.5)
|
||||||
|
try:
|
||||||
|
s = json.load(open(os.path.join(bdir, 'state.json')))
|
||||||
|
if s['remote'].get('host_id'):
|
||||||
|
brain_id = s['remote']['host_id']; break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
check('brain joins the account', brain_id, 'no host id')
|
||||||
|
# The hands: a second AnyBot of the account, lending its hands to that brain only.
|
||||||
|
_, v = http('POST', RURL + '/relay/login', {'user': USER, 'password': PW, 'device': HANDS})
|
||||||
|
_, h = http('POST', RURL + '/relay/hosts', {'name': HANDS}, token=v['token'])
|
||||||
|
hdir = os.path.join(WORK, 'hands'); os.makedirs(hdir)
|
||||||
|
hs = template(); hs['bots'] = []
|
||||||
|
hs['remote'].update({'relay_url': RURL, 'relay_user': USER, 'host_id': h['id'], 'host_token': h['token'], 'host_name': HANDS, 'device_link': True, 'hands_for': brain_id, 'hands_for_name': 'E2E大腦'})
|
||||||
|
json.dump(hs, open(os.path.join(hdir, 'state.json'), 'w'), ensure_ascii=False)
|
||||||
|
henv = {'ANYBOT_DATA_DIR': hdir, **NOBOX}
|
||||||
|
start('hands', [BIN, '--serve'], henv)
|
||||||
|
tok = h['token']
|
||||||
|
sync = lambda method, rest='', body=None, q='': http(method, f'{RURL}/relay/hosts/{brain_id}/api/sync{rest}{q}', body, token=tok)
|
||||||
|
|
||||||
|
def peers():
|
||||||
|
return {x['name']: x for x in http('GET', RURL + '/relay/host/peers', token=tok)[1]['hosts']}
|
||||||
|
for _ in range(40):
|
||||||
|
p = peers()
|
||||||
|
if p.get('E2E大腦', {}).get('online') and p.get(HANDS, {}).get('online'):
|
||||||
|
break
|
||||||
|
time.sleep(0.5)
|
||||||
|
p = peers()
|
||||||
|
check('brain and hands are online', p.get('E2E大腦', {}).get('online') and p.get(HANDS, {}).get('online'), json.dumps(p, ensure_ascii=False))
|
||||||
|
# Tell the account this computer shows that brain (as the Mac's screen does).
|
||||||
|
copy = {'version': 0, 'msgs': {}}
|
||||||
|
|
||||||
|
def pull(full=False, client='screen'):
|
||||||
|
since = 0 if full else copy['version']
|
||||||
|
code, v = sync('GET', q=f'?since={since}&client={client}')
|
||||||
|
if code != 200:
|
||||||
|
raise RuntimeError(f'pull {code} {v}')
|
||||||
|
if client == 'screen':
|
||||||
|
copy['version'] = v.get('version', 0)
|
||||||
|
for d in v.get('bots', []):
|
||||||
|
cur = copy['msgs'].setdefault(d['id'], [])
|
||||||
|
if len(cur) < d['from']:
|
||||||
|
copy['version'] = 0 # the screen asks for everything again
|
||||||
|
continue
|
||||||
|
del cur[d['from']:]
|
||||||
|
cur.extend(d['messages'])
|
||||||
|
return v
|
||||||
|
|
||||||
|
# The brain looks the account's computers up every 15 s: a computer that just came on is
|
||||||
|
# there within that.
|
||||||
|
for _ in range(25):
|
||||||
|
v = pull()
|
||||||
|
comps = v.get('computers', {}).get('1', {})
|
||||||
|
names = [x['name'] for x in comps.get('names', [])]
|
||||||
|
if HANDS in names:
|
||||||
|
break
|
||||||
|
time.sleep(1)
|
||||||
|
check('computers by their own names; no server computer', names == ['Master 的電腦', HANDS], names)
|
||||||
|
check('the bot works on its own computer by default', comps.get('current') == 'Master 的電腦', comps)
|
||||||
|
|
||||||
|
def wait_turn(answer=None, timeout=90, during=None):
|
||||||
|
"""Until the turn ends; answers requests with `answer` ("once"/"reject")."""
|
||||||
|
t0 = time.time(); seen = []
|
||||||
|
while time.time() - t0 < timeout:
|
||||||
|
v = pull(client='poll')
|
||||||
|
for a in v.get('approvals', []):
|
||||||
|
key = (a.get('call_id'), a.get('kind'))
|
||||||
|
if key not in [s[:2] for s in seen]:
|
||||||
|
seen.append((a.get('call_id'), a.get('kind'), a))
|
||||||
|
if during:
|
||||||
|
during(a)
|
||||||
|
elif answer:
|
||||||
|
sync('POST', '/act', {'bot': 1, 'action': 'answer', 'answer': answer})
|
||||||
|
if not v.get('live') and not v.get('approvals') and time.time() - t0 > 2.5:
|
||||||
|
return seen, v
|
||||||
|
time.sleep(0.5)
|
||||||
|
return seen, None
|
||||||
|
|
||||||
|
def last_tool(v):
|
||||||
|
msgs = [m for m in v['bots'][0]['messages'] if m['role'] == 'Tool'] if v and v.get('bots') else []
|
||||||
|
return msgs[-1]['text'] if msgs else ''
|
||||||
|
|
||||||
|
def full_messages():
|
||||||
|
return pull(full=True, client='check')['bots'][0]['messages']
|
||||||
|
|
||||||
|
def tool_text_after(marker):
|
||||||
|
ms = full_messages()
|
||||||
|
i = max((k for k, m in enumerate(ms) if m['role'] == 'User' and marker in m['text']), default=-1)
|
||||||
|
return [m['text'] for m in ms[i + 1:] if m['role'] == 'Tool']
|
||||||
|
|
||||||
|
# 1. 查看電腦: the person's computer is the one they typed on.
|
||||||
|
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '我現在用哪台?SC_LIST'})
|
||||||
|
wait_turn('once')
|
||||||
|
t = ' '.join(tool_text_after('SC_LIST'))
|
||||||
|
check('list_computers names the hands as where the person is', HANDS in t and '使用者現在就是在這台' in t, t[:300])
|
||||||
|
# 4. Not asked for: back to the chosen computer, with a word for the bot.
|
||||||
|
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '跑一下 SC_REDIRECT'})
|
||||||
|
wait_turn('once')
|
||||||
|
t = ' '.join(tool_text_after('SC_REDIRECT'))
|
||||||
|
check('a step the person did not ask for goes to the chosen computer, and the bot is told', 'moved-here' in t and '[Master 的電腦]' in t and '這一步你指定了' in t, t[:400])
|
||||||
|
# 2. A step on the person's computer, named by them: asks once, runs there (agent-desktop).
|
||||||
|
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦打開計算機 SC_OPEN'})
|
||||||
|
seen, _ = wait_turn('once')
|
||||||
|
t = ' '.join(tool_text_after('SC_OPEN'))
|
||||||
|
check('open_app on the person computer asks first', any(s[1] == 'action' for s in seen), seen)
|
||||||
|
check('open_app ran on the hands via agent-desktop', f'[{HANDS}]' in t and ('已打開' in t), t[:400])
|
||||||
|
# 3. A command there.
|
||||||
|
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦跑指令 SC_SHELL'})
|
||||||
|
wait_turn('once')
|
||||||
|
t = ' '.join(tool_text_after('SC_SHELL'))
|
||||||
|
check('shell runs on the hands', 'e2e-42' in t and f'[{HANDS}]' in t, t[:300])
|
||||||
|
# 5. Its own computer.
|
||||||
|
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '用你自己的 SC_OWN'})
|
||||||
|
wait_turn('once')
|
||||||
|
t = ' '.join(tool_text_after('SC_OWN'))
|
||||||
|
check('own computer by default', 'on-own-5' in t and '[Master 的電腦]' in t, t[:300])
|
||||||
|
# 6. 需要處理: 我完成了.
|
||||||
|
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦 SC_HANDOFF'})
|
||||||
|
got = {}
|
||||||
|
phone = {}
|
||||||
|
def on_handoff(a):
|
||||||
|
got.update(a)
|
||||||
|
# The phone (signed in as the person) sees it too, as 需要處理 with the computer.
|
||||||
|
_, ses = http('POST', RURL + '/relay/login', {'user': USER, 'password': PW, 'device': 'e2e-phone'})
|
||||||
|
code, view = http('GET', f'{RURL}/h/{brain_id}/api/state?since=0', token=ses.get('token'))
|
||||||
|
phone.update(next((x for x in (view or {}).get('approvals', []) if x.get('bot') == 1), {}) if code == 200 else {'code': code, 'body': str(view)[:200]})
|
||||||
|
sync('POST', '/act', {'bot': 1, 'action': 'answer', 'answer': 'once'})
|
||||||
|
wait_turn(during=on_handoff)
|
||||||
|
t = ' '.join(tool_text_after('在我的電腦 SC_HANDOFF'))
|
||||||
|
check('hand-off shows as 需要處理 with the instruction and computer', got.get('kind') == 'handoff' and got.get('reason') == 'E2E:請按「我完成了」' and got.get('grant') == HANDS, got)
|
||||||
|
check('我完成了 lets the bot carry on', '我完成了' in t, t[:300])
|
||||||
|
check('the phone shows the hand-off with its computer', phone.get('kind') == 'handoff' and phone.get('computer') == HANDS and not phone.get('can_always'), phone)
|
||||||
|
# 7. 需要處理: 跳過.
|
||||||
|
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '再一次在我的電腦 SC_HANDOFF'})
|
||||||
|
wait_turn('reject')
|
||||||
|
t = ' '.join(tool_text_after('再一次在我的電腦 SC_HANDOFF'))
|
||||||
|
check('跳過 tells the bot the step is skipped', '跳過' in t, t[:300])
|
||||||
|
# 8. 需要處理: answered by a message.
|
||||||
|
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '第三次在我的電腦 SC_HANDOFF'})
|
||||||
|
def on_reply(a):
|
||||||
|
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '我已經登入好了'})
|
||||||
|
seen, v = wait_turn(during=on_reply)
|
||||||
|
t = ' '.join(tool_text_after('第三次在我的電腦 SC_HANDOFF'))
|
||||||
|
check('a message answers a hand-off', '傳了訊息' in t, t[:300])
|
||||||
|
ms = full_messages()
|
||||||
|
check('the reply is in the chat once', sum(1 for m in ms if m['role'] == 'User' and m['text'] == '我已經登入好了') == 1, [m['text'] for m in ms if m['role'] == 'User'])
|
||||||
|
# 9. The screen's copy, kept from small updates, matches the brain's chat exactly.
|
||||||
|
for _ in range(3):
|
||||||
|
pull()
|
||||||
|
mine = copy['msgs'].get(1, [])
|
||||||
|
full = full_messages()
|
||||||
|
sig = lambda ms: [(m['role'], m['text'], m.get('at')) for m in ms]
|
||||||
|
check('screen copy equals the brain chat (no duplicates)', sig(mine) == sig(full), f'{len(mine)} vs {len(full)}')
|
||||||
|
users = [m['text'] for m in full if m['role'] == 'User']
|
||||||
|
check('no user message twice', len(users) == len(set(users)), users)
|
||||||
|
# 10. Choosing a computer from the screen reaches the brain.
|
||||||
|
sync('POST', '/edit', {'client': 'screen', 'seq': 1, 'ops': [{'op': 'bot_set', 'id': 1, 'key': 'default_computer', 'value': HANDS}]})
|
||||||
|
time.sleep(1.0)
|
||||||
|
c = pull(full=True, client='check2')['computers']['1']
|
||||||
|
check('the computer chosen on the screen is the brain\'s choice', c['current'] == HANDS, c)
|
||||||
|
sync('POST', '/edit', {'client': 'screen', 'seq': 2, 'ops': [{'op': 'bot_set', 'id': 1, 'key': 'default_computer', 'value': 'Bot 的電腦'}]})
|
||||||
|
time.sleep(1.0)
|
||||||
|
c = pull(full=True, client='check3')['computers']['1']
|
||||||
|
check('and back to its own', c['current'] == 'Master 的電腦', c)
|
||||||
|
# 11. The hands go away: plain words, not codes.
|
||||||
|
stop('hands')
|
||||||
|
time.sleep(10)
|
||||||
|
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦跑指令 SC_SHELL 二'})
|
||||||
|
wait_turn('once', timeout=120)
|
||||||
|
t = ' '.join(tool_text_after('SC_SHELL 二'))
|
||||||
|
check('hands offline: the bot hears it in words', ('連不到' in t or '離線' in t) and 'e2e-42' not in t and '這一步你指定了' not in t and not any(code in t for code in ('503', '502', '404')), t[:300])
|
||||||
|
start('hands', [BIN, '--serve'], henv)
|
||||||
|
for _ in range(40):
|
||||||
|
if peers().get(HANDS, {}).get('online'):
|
||||||
|
break
|
||||||
|
time.sleep(0.5)
|
||||||
|
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦跑指令 SC_SHELL 三'})
|
||||||
|
wait_turn('once')
|
||||||
|
t = ' '.join(tool_text_after('SC_SHELL 三'))
|
||||||
|
check('hands back: works again', 'e2e-42' in t and f'[{HANDS}]' in t, t[:300])
|
||||||
|
# 12. The brain restarts: the screen picks everything up again, and work goes on.
|
||||||
|
before = len(full_messages())
|
||||||
|
stop('brain')
|
||||||
|
time.sleep(3)
|
||||||
|
start('brain', [BIN, '--serve'], benv)
|
||||||
|
for _ in range(60):
|
||||||
|
try:
|
||||||
|
if peers().get('E2E大腦', {}).get('online'):
|
||||||
|
v = pull()
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(0.5)
|
||||||
|
time.sleep(2)
|
||||||
|
pull()
|
||||||
|
check('after a brain restart the screen has the whole chat', len(copy['msgs'].get(1, [])) >= before, f'{len(copy["msgs"].get(1, []))} < {before}')
|
||||||
|
sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '在我的電腦跑指令 SC_SHELL 四'})
|
||||||
|
wait_turn('once')
|
||||||
|
t = ' '.join(tool_text_after('SC_SHELL 四'))
|
||||||
|
check('after a brain restart the hands still work', 'e2e-42' in t and f'[{HANDS}]' in t, t[:300])
|
||||||
|
pull(); pull()
|
||||||
|
check('screen copy still equals the brain chat', sig(copy['msgs'].get(1, [])) == sig(full_messages()))
|
||||||
|
# 13. 清除聊天記錄 from the screen clears the brain's chat.
|
||||||
|
sync('POST', '/act', {'bot': 1, 'action': 'clear'})
|
||||||
|
time.sleep(1.5)
|
||||||
|
check('clear from the screen empties the brain chat', full_messages() == [])
|
||||||
|
pull(); pull()
|
||||||
|
check('and the screen copy follows', copy['msgs'].get(1, []) == [], len(copy['msgs'].get(1, [])))
|
||||||
|
|
||||||
|
try:
|
||||||
|
run()
|
||||||
|
except Exception as e:
|
||||||
|
import traceback; traceback.print_exc()
|
||||||
|
check('harness ran to the end', False, repr(e))
|
||||||
|
finally:
|
||||||
|
for n in list(procs):
|
||||||
|
stop(n)
|
||||||
|
failed = [r for r in results if not r[1]]
|
||||||
|
print(f'\n{len(results) - len(failed)}/{len(results)} passed; logs in {WORK}')
|
||||||
|
sys.exit(1 if failed else 0)
|
||||||
14
src/agent.rs
14
src/agent.rs
|
|
@ -112,9 +112,7 @@ fn drain_steer(job: &Job, history: &mut Vec<Message>, send: &dyn Fn(StreamEvent)
|
||||||
fn target_for(job: &Job, args: &str) -> Result<tools::Target, String> {
|
fn target_for(job: &Job, args: &str) -> Result<tools::Target, String> {
|
||||||
let v = tools::args_of(args);
|
let v = tools::args_of(args);
|
||||||
let want = v.get("computer").and_then(|c| c.as_str()).filter(|c| !c.is_empty()).unwrap_or(&job.default_computer).to_string();
|
let want = v.get("computer").and_then(|c| c.as_str()).filter(|c| !c.is_empty()).unwrap_or(&job.default_computer).to_string();
|
||||||
job.computers.iter().find(|t| t.name == want).cloned().ok_or_else(|| {
|
job.computers.iter().find(|t| t.name == want).cloned().ok_or_else(|| not_here(job, &want))
|
||||||
format!("沒有「{want}」這台電腦。可以用:{}", job.computers.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join("、"))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum BrowserAt {
|
enum BrowserAt {
|
||||||
|
|
@ -225,6 +223,9 @@ fn keep_to_chosen(job: &Job, history: &[Message], args: &str) -> (String, Option
|
||||||
let said = history.iter().rev().filter(|m| m.role == Role::User && !m.summary).take(3).map(|m| m.text.as_str()).collect::<Vec<_>>().join("\n");
|
let said = history.iter().rev().filter(|m| m.role == Role::User && !m.summary).take(3).map(|m| m.text.as_str()).collect::<Vec<_>>().join("\n");
|
||||||
match job.computers.iter().find(|t| t.name == named) {
|
match job.computers.iter().find(|t| t.name == named) {
|
||||||
Some(t) if mentions(&said, t, &job.roles) => (args.to_string(), None),
|
Some(t) if mentions(&said, t, &job.roles) => (args.to_string(), None),
|
||||||
|
// A computer that isn't on the list now (switched off, its AnyBot closed): the step says
|
||||||
|
// so instead of quietly running somewhere else.
|
||||||
|
None => (args.to_string(), None),
|
||||||
_ => {
|
_ => {
|
||||||
v["computer"] = serde_json::Value::String(job.default_computer.clone());
|
v["computer"] = serde_json::Value::String(job.default_computer.clone());
|
||||||
let note = format!("(這一步你指定了「{named}」,但使用者選的電腦是「{}」,而且他沒有說要換,所以這一步在「{}」上做。真的需要別台,先問使用者。)\n", job.default_computer, job.default_computer);
|
let note = format!("(這一步你指定了「{named}」,但使用者選的電腦是「{}」,而且他沒有說要換,所以這一步在「{}」上做。真的需要別台,先問使用者。)\n", job.default_computer, job.default_computer);
|
||||||
|
|
@ -241,6 +242,11 @@ fn noted(moved: &std::collections::HashMap<String, String>, id: &str, text: &str
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A computer the step names that isn't among this bot's computers right now, in plain words.
|
||||||
|
fn not_here(job: &Job, name: &str) -> String {
|
||||||
|
format!("「{name}」現在連不到:那台電腦可能沒開,或上面的 AnyBot 沒開、沒登入。請告訴使用者,等它連上再做(或問他要不要改用別台)。現在可以用:{}", job.computers.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join("、"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Does the call name a computer?
|
/// Does the call name a computer?
|
||||||
fn names_a_computer(args: &str) -> bool {
|
fn names_a_computer(args: &str) -> bool {
|
||||||
tools::args_of(args).get("computer").and_then(|c| c.as_str()).map(|c| !c.trim().is_empty()).unwrap_or(false)
|
tools::args_of(args).get("computer").and_then(|c| c.as_str()).map(|c| !c.trim().is_empty()).unwrap_or(false)
|
||||||
|
|
@ -254,7 +260,7 @@ fn screen_target(job: &Job, args: &str) -> Result<tools::Target, String> {
|
||||||
let has_screen = |t: &tools::Target| matches!(t.place, tools::Place::Box { .. } | tools::Place::Local { .. } | tools::Place::Device { .. });
|
let has_screen = |t: &tools::Target| matches!(t.place, tools::Place::Box { .. } | tools::Place::Local { .. } | tools::Place::Device { .. });
|
||||||
let named = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty());
|
let named = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty());
|
||||||
if let Some(n) = named {
|
if let Some(n) = named {
|
||||||
let t = job.computers.iter().find(|t| t.name == n).ok_or_else(|| format!("沒有「{n}」這台電腦。可以用:{}", job.computers.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join("、")))?;
|
let t = job.computers.iter().find(|t| t.name == n).ok_or_else(|| not_here(job, n))?;
|
||||||
if !has_screen(t) {
|
if !has_screen(t) {
|
||||||
return Err(if n == job.roles.own {
|
return Err(if n == job.roles.own {
|
||||||
format!("{n}現在沒有螢幕:AnyBot 所在的電腦沒有 Docker({})。要用別台請使用者指名。", if cfg!(target_os = "macos") { "安裝 OrbStack 或 Docker Desktop 就會有" } else { "安裝 Docker Engine 就會有" })
|
format!("{n}現在沒有螢幕:AnyBot 所在的電腦沒有 Docker({})。要用別台請使用者指名。", if cfg!(target_os = "macos") { "安裝 OrbStack 或 Docker Desktop 就會有" } else { "安裝 Docker Engine 就會有" })
|
||||||
|
|
|
||||||
|
|
@ -131,8 +131,9 @@ impl App {
|
||||||
if !self.mir.on && s.auto_backup && !s.backup_key.is_empty() && !s.relay_url.is_empty() && chrono::Utc::now().timestamp() - s.last_backup > 86_400 && !self.rm.busy.load(Ordering::SeqCst) {
|
if !self.mir.on && s.auto_backup && !s.backup_key.is_empty() && !s.relay_url.is_empty() && chrono::Utc::now().timestamp() - s.last_backup > 86_400 && !self.rm.busy.load(Ordering::SeqCst) {
|
||||||
self.remote_backup();
|
self.remote_backup();
|
||||||
}
|
}
|
||||||
// 裝置連結: keep the list of this account's computers fresh (once a minute).
|
// 裝置連結: keep the list of this account's computers fresh (every 15 s: a computer that
|
||||||
if !self.state.remote.host_token.is_empty() && !self.rm.peers_at.map(|t| t.elapsed().as_secs() < 60).unwrap_or(false) {
|
// comes on is one the bots can use soon after).
|
||||||
|
if !self.state.remote.host_token.is_empty() && !self.rm.peers_at.map(|t| t.elapsed().as_secs() < 15).unwrap_or(false) {
|
||||||
self.rm.peers_at = Some(Instant::now());
|
self.rm.peers_at = Some(Instant::now());
|
||||||
let (peers, s) = (self.rm.peers.clone(), self.state.remote.clone());
|
let (peers, s) = (self.rm.peers.clone(), self.state.remote.clone());
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
|
|
@ -268,6 +269,15 @@ impl App {
|
||||||
// 「我的電腦」 and the model on their network). The phone isn't a computer.
|
// 「我的電腦」 and the model on their network). The phone isn't a computer.
|
||||||
if !from.is_empty() {
|
if !from.is_empty() {
|
||||||
remote::set_person_host(&from);
|
remote::set_person_host(&from);
|
||||||
|
// A computer this brain hasn't heard of yet (it just came on): look the
|
||||||
|
// account's computers up now, or its 「我的電腦」 wouldn't be known for a minute.
|
||||||
|
if !self.rm.peers.lock().unwrap().iter().any(|(id, _, online)| *id == from && *online) {
|
||||||
|
let v = remote::devices_online(&self.state.remote);
|
||||||
|
if !v.is_empty() {
|
||||||
|
*self.rm.peers.lock().unwrap() = v;
|
||||||
|
}
|
||||||
|
self.rm.peers_at = Some(Instant::now());
|
||||||
|
}
|
||||||
self.typed_on = Some(from);
|
self.typed_on = Some(from);
|
||||||
}
|
}
|
||||||
// Only files the app itself stored in its attachments folder may ride along;
|
// Only files the app itself stored in its attachments folder may ride along;
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,9 @@ pub const HOST_PORT: u16 = 16080;
|
||||||
pub const SCREEN: &str = "1280x800x24";
|
pub const SCREEN: &str = "1280x800x24";
|
||||||
/// Health probe timeout (QQn).
|
/// Health probe timeout (QQn).
|
||||||
pub const HEALTH_TIMEOUT_MS: u64 = 3_000;
|
pub const HEALTH_TIMEOUT_MS: u64 = 3_000;
|
||||||
/// Snapshot ("flush the durable store") timeout (VQn).
|
/// Saving a snapshot ("flush the durable store"). The original waits 30 s (VQn); a home folder of
|
||||||
pub const SNAPSHOT_TIMEOUT_MS: u64 = 30_000;
|
/// a GB with an open browser takes minutes to pack, and a cut-off snapshot is no snapshot.
|
||||||
|
pub const SNAPSHOT_SAVE_TIMEOUT_MS: u64 = 600_000;
|
||||||
/// Restore copy-in: longest wait and poll interval (zQn, HQn).
|
/// Restore copy-in: longest wait and poll interval (zQn, HQn).
|
||||||
pub const COPY_IN_WAIT_MS: u64 = 90_000;
|
pub const COPY_IN_WAIT_MS: u64 = 90_000;
|
||||||
pub const COPY_IN_POLL_MS: u64 = 2_000;
|
pub const COPY_IN_POLL_MS: u64 = 2_000;
|
||||||
|
|
@ -747,7 +748,11 @@ pub fn save_snapshot() -> Result<u64, String> {
|
||||||
let tmp = data_dir().join("snapshot.tmp");
|
let tmp = data_dir().join("snapshot.tmp");
|
||||||
let file = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;
|
let file = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;
|
||||||
let mut child = docker()
|
let mut child = docker()
|
||||||
.args(["exec", CONTAINER, "tar", "-C", HOME, "--exclude=./.cache", "-czf", "-", "."])
|
// Caches are left out: the browser's grow to hundreds of MB and come back by themselves.
|
||||||
|
.args([
|
||||||
|
"exec", CONTAINER, "tar", "-C", HOME, "--exclude=./.cache", "--exclude=Cache", "--exclude=Code Cache", "--exclude=GPUCache", "--exclude=CacheStorage", "--exclude=ScriptCache", "--exclude=Crashpad",
|
||||||
|
"--warning=no-file-changed", "-czf", "-", ".",
|
||||||
|
])
|
||||||
.stdout(Stdio::from(file))
|
.stdout(Stdio::from(file))
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
.spawn()
|
.spawn()
|
||||||
|
|
@ -763,10 +768,12 @@ pub fn save_snapshot() -> Result<u64, String> {
|
||||||
});
|
});
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let ok = loop {
|
let ok = loop {
|
||||||
|
// tar says 1 when a file changed while it was read (the open browser writes all the
|
||||||
|
// time): the archive is still whole. Only 2 and up are failures.
|
||||||
if let Ok(Some(st)) = child.try_wait() {
|
if let Ok(Some(st)) = child.try_wait() {
|
||||||
break st.success();
|
break st.code().map(|c| c <= 1).unwrap_or(false);
|
||||||
}
|
}
|
||||||
if start.elapsed() > Duration::from_millis(SNAPSHOT_TIMEOUT_MS * 4) {
|
if start.elapsed() > Duration::from_millis(SNAPSHOT_SAVE_TIMEOUT_MS) {
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
break false;
|
break false;
|
||||||
}
|
}
|
||||||
|
|
@ -1527,15 +1534,29 @@ mod tests {
|
||||||
println!("{txt}");
|
println!("{txt}");
|
||||||
assert!(img.is_some());
|
assert!(img.is_some());
|
||||||
run_on("shell", r#"{"command":"(xterm >/dev/null 2>&1 &) ; sleep 1.5"}"#, &t, &cancel, &ids);
|
run_on("shell", r#"{"command":"(xterm >/dev/null 2>&1 &) ; sleep 1.5"}"#, &t, &cancel, &ids);
|
||||||
computer_run("computer_use_click", &serde_json::json!({"x": 200, "y": 150})).unwrap();
|
// Click the middle of that window, wherever it opened (other windows — a browser left
|
||||||
|
// open — may cover the corner), in the screenshot's pixels. (Its shell renames it, so it
|
||||||
|
// is found by its class: the newest xterm.)
|
||||||
|
let (geo, _) = run_on("shell", r#"{"command":"W=$(DISPLAY=:1 xdotool search --onlyvisible --class xterm | tail -1); DISPLAY=:1 xdotool getwindowgeometry --shell $W | tail -n +2 | head -4 | tr '\n' ' '"}"#, &t, &cancel, &ids);
|
||||||
|
let num = |k: &str| geo.split_whitespace().find_map(|w| w.strip_prefix(&format!("{k}=")).and_then(|v| v.parse::<f64>().ok())).unwrap_or(0.0);
|
||||||
|
let (cx, cy) = (num("X") + num("WIDTH") / 2.0, num("Y") + num("HEIGHT") / 2.0);
|
||||||
|
let k = 1024.0 / 1280.0;
|
||||||
|
computer_run("computer_use_click", &serde_json::json!({"x": cx * k, "y": cy * k})).unwrap();
|
||||||
computer_run("computer_use_typing", &serde_json::json!({"value": "echo 中文輸入OK > /home/bot/typed.txt"})).unwrap();
|
computer_run("computer_use_typing", &serde_json::json!({"value": "echo 中文輸入OK > /home/bot/typed.txt"})).unwrap();
|
||||||
computer_run("computer_use_press_key", &serde_json::json!({"key": "return"})).unwrap();
|
computer_run("computer_use_press_key", &serde_json::json!({"key": "return"})).unwrap();
|
||||||
std::thread::sleep(Duration::from_millis(800));
|
std::thread::sleep(Duration::from_millis(800));
|
||||||
let (out, _) = run_on("shell", r#"{"command":"cat /home/bot/typed.txt"}"#, &t, &cancel, &ids);
|
let (out, _) = run_on("shell", r#"{"command":"cat /home/bot/typed.txt"}"#, &t, &cancel, &ids);
|
||||||
println!("typed: {out}");
|
println!("typed: {out}");
|
||||||
assert!(out.contains("中文輸入OK"), "{out}");
|
assert!(out.contains("中文輸入OK"), "{out}");
|
||||||
// snapshot + restore
|
// snapshot (with the browser and its caches in the home folder), then restore — which
|
||||||
save_snapshot().unwrap();
|
// rebuilds the whole box, so only on a box meant for it (ANYBOT_TEST_RESTORE=1).
|
||||||
|
let size = save_snapshot().expect("snapshot saves even while the browser writes");
|
||||||
|
println!("snapshot: {}", human(size));
|
||||||
|
assert!(size > 0);
|
||||||
|
if std::env::var("ANYBOT_TEST_RESTORE").as_deref() != Ok("1") {
|
||||||
|
let _ = run_on("shell", r#"{"command":"rm -rf /home/bot/bot-999 /home/bot/typed.txt"}"#, &t, &cancel, &ids);
|
||||||
|
return;
|
||||||
|
}
|
||||||
run_on("shell", r#"{"command":"rm -rf notes"}"#, &t, &cancel, &ids);
|
run_on("shell", r#"{"command":"rm -rf notes"}"#, &t, &cancel, &ids);
|
||||||
rebuild(Rebuild::Restore).unwrap();
|
rebuild(Rebuild::Restore).unwrap();
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
|
|
|
||||||
11
src/main.rs
11
src/main.rs
|
|
@ -177,6 +177,17 @@ fn write_icon(out: &str, px: u32) -> Result<(), String> {
|
||||||
fn main() -> eframe::Result<()> {
|
fn main() -> eframe::Result<()> {
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
let screenshot = args.iter().position(|a| a == "--screenshot").and_then(|i| args.get(i + 1)).map(PathBuf::from);
|
let screenshot = args.iter().position(|a| a == "--screenshot").and_then(|i| args.get(i + 1)).map(PathBuf::from);
|
||||||
|
// For tests that start AnyBots of their own: a whole settings file (the sample bots) to edit.
|
||||||
|
if args.get(1).map(String::as_str) == Some("--sample-state") {
|
||||||
|
let out = args.get(2).cloned().unwrap_or_else(|| "state.json".into());
|
||||||
|
let mut st = model::State::samples();
|
||||||
|
st.remote = Default::default();
|
||||||
|
if let Err(e) = std::fs::write(&out, serde_json::to_vec_pretty(&st).unwrap_or_default()) {
|
||||||
|
eprintln!("{e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
if args.get(1).map(String::as_str) == Some("--write-icon") {
|
if args.get(1).map(String::as_str) == Some("--write-icon") {
|
||||||
let out = args.get(2).cloned().unwrap_or_else(|| "anybot.png".into());
|
let out = args.get(2).cloned().unwrap_or_else(|| "anybot.png".into());
|
||||||
let px = args.get(3).and_then(|v| v.parse().ok()).unwrap_or(256);
|
let px = args.get(3).and_then(|v| v.parse().ok()).unwrap_or(256);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue