From e44614736fd563330cdc8b6232c0e9afdcdc851a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 19:41:49 +0800 Subject: [PATCH] =?UTF-8?q?=E3=80=8C=E6=88=91=E7=9A=84=E9=9B=BB=E8=85=A6?= =?UTF-8?q?=E3=80=8D=20follows=20the=20computer=20the=20person=20types=20o?= =?UTF-8?q?n,=20also=20mid-turn=20and=20after=20a=20restart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turn started from the phone (not a computer) kept 「don't know」 for its whole run: a message typed on the Mac while it waited on a hand-off had its 「我的電腦」 moved to the bot's own computer. Each step now asks which computer the person last typed on, and the brain keeps it beside its data (person-computer.txt) across restarts. remote_e2e: a second computer, and a mid-turn message from the other one (fails without the fix); 28 checks. Co-Authored-By: Claude Opus 5.5 --- deploy/test/remote_e2e.py | 31 +++++++++++++++++++++++++++---- src/agent.rs | 28 +++++++++++++++++++++------- src/app.rs | 6 ++++++ src/remote.rs | 22 ++++++++++++++++++++-- 4 files changed, 74 insertions(+), 13 deletions(-) diff --git a/deploy/test/remote_e2e.py b/deploy/test/remote_e2e.py index 43ff88e..a367f70 100644 --- a/deploy/test/remote_e2e.py +++ b/deploy/test/remote_e2e.py @@ -33,6 +33,7 @@ SCRIPTS = { '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})], + '另一台:': [tool_call('hand_over_computer', {'instruction': 'E2E:在另一台', 'computer': 'E2E另一台'})], 'SC_OWN': [tool_call('shell', {'command': 'echo on-own-$((2+3))'})], } @@ -147,6 +148,14 @@ def run(): 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) + # A second computer showing the same brain (as a desktop AnyBot left on the server does): + # with two, 「我的電腦」 has to be the one the person typed on, not a guess. + _, h2 = http('POST', RURL + '/relay/hosts', {'name': 'E2E另一台'}, token=v['token']) + h2dir = os.path.join(WORK, 'hands2'); os.makedirs(h2dir) + hs2 = template(); hs2['bots'] = [] + hs2['remote'].update({'relay_url': RURL, 'relay_user': USER, 'host_id': h2['id'], 'host_token': h2['token'], 'host_name': 'E2E另一台', 'device_link': True, 'hands_for': brain_id, 'hands_for_name': 'E2E大腦', 'brain_id': brain_id, 'brain_name': 'E2E大腦'}) + json.dump(hs2, open(os.path.join(h2dir, 'state.json'), 'w'), ensure_ascii=False) + start('hands2', [BIN, '--serve'], {'ANYBOT_DATA_DIR': h2dir, **NOBOX}) 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) @@ -154,7 +163,7 @@ def run(): 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'): + if p.get('E2E大腦', {}).get('online') and p.get(HANDS, {}).get('online') and p.get('E2E另一台', {}).get('online'): break time.sleep(0.5) p = peers() @@ -184,10 +193,10 @@ def run(): v = pull() comps = v.get('computers', {}).get('1', {}) names = [x['name'] for x in comps.get('names', [])] - if HANDS in names: + if HANDS in names and 'E2E另一台' in names: break time.sleep(1) - check('computers by their own names; no server computer', names == ['Master 的電腦', HANDS], names) + check('computers by their own names; no server computer', sorted(names) == sorted(['Master 的電腦', HANDS, 'E2E另一台']), 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): @@ -276,6 +285,20 @@ def run(): 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']) + # 8b. A turn started on one computer; while it waits, the person types on another one: + # 「我的電腦」 in that message is the computer it was typed on (the turn's own idea was fixed + # when it started). + tok2 = h2['token'] + sync2 = lambda method, rest='', body=None, q='': http(method, f'{RURL}/relay/hosts/{brain_id}/api/sync{rest}{q}', body, token=tok2) + sync2('POST', '/act', {'bot': 1, 'action': 'send', 'text': '另一台:在這台 E2E另一台 請我接手'}) + def steer_from_hands(a): + if a.get('kind') == 'handoff': + sync('POST', '/act', {'bot': 1, 'action': 'send', 'text': '算了,在我的電腦跑指令 SC_SHELL 插話'}) + else: + sync('POST', '/act', {'bot': 1, 'action': 'answer', 'answer': 'once'}) + wait_turn(during=steer_from_hands) + t = ' '.join(tool_text_after('SC_SHELL 插話')) + check('a message typed on another computer mid-turn: 「我的電腦」 is that one', 'e2e-42' in t and f'[{HANDS}]' in t and '這一步你指定了' not in t, t[:300]) # 9. The screen's copy, kept from small updates, matches the brain's chat exactly. for _ in range(3): pull() @@ -329,7 +352,7 @@ def run(): 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]) + check('after a brain restart, the first 「我的電腦」 is the computer the person typed on', 'e2e-42' in t and f'[{HANDS}]' in t and '這一步你指定了' not 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. diff --git a/src/agent.rs b/src/agent.rs index 7d30f83..06b7572 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -140,7 +140,7 @@ fn browser_place(job: &Job, name: &str, args: &str) -> (BrowserAt, String) { if let Some((b, n)) = job.computers.iter().find(|t| t.name == want).and_then(|t| at(t).map(|b| (b, t.name.clone()))) { return (b, n); } - let person = job.roles.person.as_ref().and_then(|p| job.computers.iter().find(|t| &t.name == p)); + let person = roles_now(job).person.and_then(|p| job.computers.iter().find(|t| t.name == p)); match person.and_then(|t| at(t).map(|b| (b, t.name.clone()))).or_else(|| job.computers.iter().find_map(|t| at(t).map(|b| (b, t.name.clone())))) { Some(x) => x, None => (BrowserAt::Here, String::new()), @@ -222,7 +222,7 @@ 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::>().join("\n"); 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, &roles_now(job)) => (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), @@ -255,6 +255,20 @@ fn not_here(job: &Job, name: &str) -> String { format!("「{name}」現在連不到:那台電腦可能沒開,或上面的 AnyBot 沒開、沒登入。請告訴使用者,等它連上再做(或問他要不要改用別台)。現在可以用:{}", job.computers.iter().map(|t| t.name.as_str()).collect::>().join("、")) } +/// Which computer is the person's right now: a message typed on one of the account's computers +/// while the turn runs (a steer, or the answer to a hand-off) moves it there — the turn's own +/// idea was fixed when it started, maybe from the phone, which isn't a computer. +fn roles_now(job: &Job) -> tools::Roles { + let mut r = job.roles.clone(); + let host = crate::remote::person_host(); + if !host.is_empty() { + if let Some(t) = job.computers.iter().find(|t| matches!(&t.place, tools::Place::Device { host: h } if *h == host)) { + r.person = Some(t.name.clone()); + } + } + r +} + /// Does the call name a computer? 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) @@ -635,7 +649,8 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< }; // On a server nobody sits at its screen: a link opens in the browser of the // computer the person is using; a file is sent into the chat instead. - let person_host = job.roles.person.as_ref().and_then(|n| job.computers.iter().find(|t| &t.name == n)).and_then(|t| match &t.place { + let person_now = roles_now(&job).person; + let person_host = person_now.as_ref().and_then(|n| job.computers.iter().find(|t| &t.name == n)).and_then(|t| match &t.place { tools::Place::Device { host } => Some(host.clone()), _ => None, }); @@ -643,7 +658,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< let web = path.starts_with("http://") || path.starts_with("https://"); let (output, ok) = match (&person_host, web) { (Some(host), true) => match crate::remote::run_on_device(host, "browser_open", &serde_json::json!({"url": path}).to_string(), &job.via_name) { - Ok(v) => (format!("[{}] {}", job.roles.person.clone().unwrap_or_default(), v["text"].as_str().unwrap_or("")), v["ok"].as_bool().unwrap_or(false)), + Ok(v) => (format!("[{}] {}", person_now.clone().unwrap_or_default(), v["text"].as_str().unwrap_or("")), v["ok"].as_bool().unwrap_or(false)), Err(e) => (e, false), }, (None, true) => ("不知道使用者現在在用哪台電腦,沒辦法打開給他看。把網址直接寫在回覆裡。".to_string(), false), @@ -856,10 +871,9 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< if names_a_computer(&call.args) { return target_for(&job, &call.args); } - job.roles + roles_now(&job) .person - .as_ref() - .and_then(|n| job.computers.iter().find(|t| &t.name == n)) + .and_then(|n| job.computers.iter().find(|t| t.name == n)) .cloned() .ok_or_else(|| format!("不知道使用者現在在用哪台電腦。請問他要在哪台做(可以用:{})。", job.computers.iter().map(|t| t.name.as_str()).collect::>().join("、"))) }; diff --git a/src/app.rs b/src/app.rs index 10fb1a8..eb9583d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -303,6 +303,12 @@ impl App { }; crate::i18n::set(&app.state.language); app.computers_start(); + // The computer the person used last time (a brain that restarted for an update). + if !app.demo { + if let Some(h) = crate::remote::load_person_host() { + app.typed_on = Some(h); + } + } if demo { // The computer helper's pending step, so the picture shows the approval card. let (reply, _) = channel(); diff --git a/src/remote.rs b/src/remote.rs index bc149e3..3b2e8b6 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -1427,9 +1427,27 @@ fn person_cell() -> &'static Mutex { P.get_or_init(Mutex::default) } -/// The account computer the person last typed on (they are sitting at it). +/// The account computer the person last typed on (they are sitting at it). Kept beside the data, +/// so a brain that restarts (an update) still knows it. pub fn set_person_host(id: &str) { - *person_cell().lock().unwrap() = id.to_string(); + let mut cur = person_cell().lock().unwrap(); + if *cur != id { + *cur = id.to_string(); + let _ = std::fs::write(person_file(), id); + } +} + +fn person_file() -> std::path::PathBuf { + crate::model::State::path().with_file_name("person-computer.txt") +} + +/// At start: the computer the person used last time. +pub fn load_person_host() -> Option { + let id = std::fs::read_to_string(person_file()).ok()?.trim().to_string(); + (!id.is_empty() && id.len() <= 64 && id.chars().all(|c| c.is_ascii_alphanumeric())).then(|| { + *person_cell().lock().unwrap() = id.clone(); + id + }) } pub fn person_host() -> String {