feat/cua-driver-poc #7
File diff suppressed because one or more lines are too long
|
|
@ -175,7 +175,7 @@ export function CallOverlay({
|
||||||
<button type="button" className="call-hangup" onClick={hangUp}>{t("hangUp")}</button>
|
<button type="button" className="call-hangup" onClick={hangUp}>{t("hangUp")}</button>
|
||||||
</div>
|
</div>
|
||||||
{showTakeover ? (
|
{showTakeover ? (
|
||||||
<button type="button" className="primary call-takeover" onClick={onTakeOver}>{t("takeOverNow")}</button>
|
<button type="button" className="primary call-takeover" onClick={onTakeOver}>{t("loginOpenScreen")}</button>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="call-hint">{t("callShortcuts")}</p>
|
<p className="call-hint">{t("callShortcuts")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,8 @@ export function nextVeil(label: string | null, current: Veil): Veil {
|
||||||
return current.leaving ? current : { label: current.label, leaving: true };
|
return current.leaving ? current : { label: current.label, leaving: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The lease is advisory inside the container, so the browser gate is what
|
/** Shared desktop input stays enabled while the agent works or waits for help.
|
||||||
* actually moves the mouse: whoever holds control may send input right now. */
|
* Older servers still use the exclusive holder gate. */
|
||||||
export function viewOnlyFor(holder: "none" | "bot" | "user"): boolean {
|
export function viewOnlyFor(holder: "none" | "bot" | "user", sharedInput = false): boolean {
|
||||||
return holder !== "user";
|
return !sharedInput && holder !== "user";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ import type { zhTW } from "./zh-TW";
|
||||||
|
|
||||||
/** English UI copy. Keys must stay in lockstep with zh-TW. */
|
/** English UI copy. Keys must stay in lockstep with zh-TW. */
|
||||||
export const en: { [K in keyof typeof zhTW]: string } = {
|
export const en: { [K in keyof typeof zhTW]: string } = {
|
||||||
|
sharedDesktop: "Shared control",
|
||||||
|
sharedNeedsUser: "{name} is waiting for you to finish the steps on screen. Then press “Done, continue”.",
|
||||||
|
doneContinue: "Done, continue",
|
||||||
search: "Search",
|
search: "Search",
|
||||||
sharedComputer: "Shared computer",
|
sharedComputer: "Shared computer",
|
||||||
privateComputer: "Private computer",
|
privateComputer: "Private computer",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
/** Traditional Chinese UI copy. Keep keys stable when adding another locale. */
|
/** Traditional Chinese UI copy. Keep keys stable when adding another locale. */
|
||||||
export const zhTW = {
|
export const zhTW = {
|
||||||
|
sharedDesktop: "共同操作",
|
||||||
|
sharedNeedsUser: "{name} 已暫停等你完成畫面上的步驟。完成後按「完成,繼續」。",
|
||||||
|
doneContinue: "完成,繼續",
|
||||||
search: "搜尋", sharedComputer: "共用電腦", privateComputer: "私人電腦",
|
search: "搜尋", sharedComputer: "共用電腦", privateComputer: "私人電腦",
|
||||||
stopped: "已關閉", booting: "啟動中", running: "執行中", suspended: "休眠中", error: "發生錯誤",
|
stopped: "已關閉", booting: "啟動中", running: "執行中", suspended: "休眠中", error: "發生錯誤",
|
||||||
openComputer: "開啟電腦", stopTask: "停止任務", takeControl: "取得控制", takeOverNow: "接手操作", releaseControl: "釋放控制", done: "完成", skip: "略過",
|
openComputer: "開啟電腦", stopTask: "停止任務", takeControl: "取得控制", takeOverNow: "接手操作", releaseControl: "釋放控制", done: "完成", skip: "略過",
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export interface Message { id:string; sessionId?:string; seq?:number; role:strin
|
||||||
export interface MessageFile { kind:"image"|"file"; name:string; mimeType?:string; size?:number }
|
export interface MessageFile { kind:"image"|"file"; name:string; mimeType?:string; size?:number }
|
||||||
export interface RoomMember { id:string; name:string; avatarColor:string; avatarShape:AvatarShape }
|
export interface RoomMember { id:string; name:string; avatarColor:string; avatarShape:AvatarShape }
|
||||||
export interface Room { id:string; name:string; members:RoomMember[]; lastMessageAt:string|null; lastPreview:string|null; unreadCount:number }
|
export interface Room { id:string; name:string; members:RoomMember[]; lastMessageAt:string|null; lastPreview:string|null; unreadCount:number }
|
||||||
export interface ComputerStatus { botId:string; mode:ComputerMode; state:ComputerState; controlHolder:"none"|"bot"|"user"; takeoverRequested:boolean; busyBotName:string|null; busySessionId:string|null; busyRunId:string|null; busyStep?:string|null; usingComputer?:boolean; waitingRunId?:string|null; waitingSessionId?:string|null; queuedRuns?:number; display:string|null; profileMode:string; screenAvailable:boolean }
|
export interface ComputerStatus { botId:string; mode:ComputerMode; state:ComputerState; sharedInput?:boolean; controlHolder:"none"|"bot"|"user"; takeoverRequested:boolean; busyBotName:string|null; busySessionId:string|null; busyRunId:string|null; busyStep?:string|null; usingComputer?:boolean; waitingRunId?:string|null; waitingSessionId?:string|null; queuedRuns?:number; display:string|null; profileMode:string; screenAvailable:boolean }
|
||||||
/** One line of the live trail a run writes while it works. */
|
/** One line of the live trail a run writes while it works. */
|
||||||
export type RunActivityKind = "run"|"model"|"tool"|"retry"|"notice";
|
export type RunActivityKind = "run"|"model"|"tool"|"retry"|"notice";
|
||||||
export interface RunActivityEntry { id:number; kind:RunActivityKind; createdAt:string; turn?:number|null; event?:string|null; task?:string|null; reason?:string|null; turns?:number|null; limit?:number|null; error?:string|null; name?:string|null; step?:string|null; status?:string|null; elapsedMs?:number|null; toolCalls?:number|null; text?:string|null; snippet?:string|null; attempt?:number|null; gaveUp?:boolean|null }
|
export interface RunActivityEntry { id:number; kind:RunActivityKind; createdAt:string; turn?:number|null; event?:string|null; task?:string|null; reason?:string|null; turns?:number|null; limit?:number|null; error?:string|null; name?:string|null; step?:string|null; status?:string|null; elapsedMs?:number|null; toolCalls?:number|null; text?:string|null; snippet?:string|null; attempt?:number|null; gaveUp?:boolean|null }
|
||||||
|
|
|
||||||
|
|
@ -382,7 +382,7 @@
|
||||||
setStatus("Connecting to desktop…");
|
setStatus("Connecting to desktop…");
|
||||||
window.parent.postMessage({ type: "lazyboy-desktop-lost" }, parentOrigin);
|
window.parent.postMessage({ type: "lazyboy-desktop-lost" }, parentOrigin);
|
||||||
rfb = new RFB(document.getElementById("screen"), url);
|
rfb = new RFB(document.getElementById("screen"), url);
|
||||||
rfb.viewOnly = flag("view_only", true);
|
rfb.viewOnly = wantedViewOnly ?? flag("view_only", true);
|
||||||
rfb.scaleViewport = true;
|
rfb.scaleViewport = true;
|
||||||
rfb.qualityLevel = 6;
|
rfb.qualityLevel = 6;
|
||||||
rfb.compressionLevel = 2;
|
rfb.compressionLevel = 2;
|
||||||
|
|
@ -402,7 +402,11 @@
|
||||||
});
|
});
|
||||||
rfb.addEventListener("disconnect", (event) => {
|
rfb.addEventListener("disconnect", (event) => {
|
||||||
releaseDrag(); gesture = null;
|
releaseDrag(); gesture = null;
|
||||||
applyViewOnly(true);
|
// Disable the disconnected instance without overwriting host intent.
|
||||||
|
rfb.viewOnly = true;
|
||||||
|
modifier = null;
|
||||||
|
if (shortcuts) shortcuts.hidden = true;
|
||||||
|
updatePointerControls();
|
||||||
keyboard.blur(); resetKeyboard();
|
keyboard.blur(); resetKeyboard();
|
||||||
window.parent.postMessage({ type: "lazyboy-desktop-lost" }, parentOrigin);
|
window.parent.postMessage({ type: "lazyboy-desktop-lost" }, parentOrigin);
|
||||||
const clean = event && event.detail && event.detail.clean;
|
const clean = event && event.detail && event.detail.clean;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use lazyboy_contracts::{
|
||||||
use lazyboy_control::{
|
use lazyboy_control::{
|
||||||
AdapterContext, CommandRequest, EnsureScreenRequest, ProvisionRequest, admit_gui,
|
AdapterContext, CommandRequest, EnsureScreenRequest, ProvisionRequest, admit_gui,
|
||||||
admit_new_screen, browser_profile_path, execution_blocks_user_takeover, profile_lock_key,
|
admit_new_screen, browser_profile_path, execution_blocks_user_takeover, profile_lock_key,
|
||||||
screen_layout, team_bot_workspace_directory, user_holds_control,
|
screen_layout, team_bot_workspace_directory,
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
@ -46,6 +46,7 @@ pub fn status_from(
|
||||||
mode: parse_mode(&computer.scope),
|
mode: parse_mode(&computer.scope),
|
||||||
kind: parse_kind(&computer.kind),
|
kind: parse_kind(&computer.kind),
|
||||||
state: parse_state(&computer.state),
|
state: parse_state(&computer.state),
|
||||||
|
shared_input: true,
|
||||||
control_holder,
|
control_holder,
|
||||||
control_bot_id,
|
control_bot_id,
|
||||||
takeover_requested: false,
|
takeover_requested: false,
|
||||||
|
|
@ -910,7 +911,7 @@ pub async fn takeover(
|
||||||
&thread_id,
|
&thread_id,
|
||||||
&run_id,
|
&run_id,
|
||||||
bot_id,
|
bot_id,
|
||||||
"你已接手操作。完成後釋放控制權,我會從目前畫面繼續。",
|
"我已暫停等你操作。完成後按「完成,繼續」,我會從目前畫面接著做。",
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
@ -998,27 +999,12 @@ pub async fn heartbeat(state: &AppState, actor: &Actor, bot_id: &str) -> Result<
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn user_has_screen_control(
|
/// Viewing/input does not acquire the human pause lease. Agent execution and
|
||||||
computer: &ComputerRow,
|
/// explicit requests for human assistance retain their own pause/resume flow.
|
||||||
screen: Option<&ScreenRow>,
|
pub fn user_can_interact(computer: &ComputerRow, screen: Option<&ScreenRow>, bot_id: &str) -> bool {
|
||||||
bot_id: &str,
|
computer.state == "running"
|
||||||
) -> bool {
|
&& computer.provider_ref.is_some()
|
||||||
if let Some(screen) = screen {
|
&& screen.is_some_and(|screen| screen.bot_id == bot_id && screen.computer_id == computer.id)
|
||||||
return user_holds_control(
|
|
||||||
parse_holder(&screen.control_holder),
|
|
||||||
Some(screen.bot_id.as_str()),
|
|
||||||
bot_id,
|
|
||||||
screen.control_lease_expires_at,
|
|
||||||
Utc::now(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
user_holds_control(
|
|
||||||
parse_holder(&computer.control_holder),
|
|
||||||
computer.control_bot_id.as_deref(),
|
|
||||||
bot_id,
|
|
||||||
computer.control_lease_expires_at,
|
|
||||||
Utc::now(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn idle_loop(state: AppState) {
|
pub async fn idle_loop(state: AppState) {
|
||||||
|
|
@ -1227,3 +1213,78 @@ struct ActiveRunRow {
|
||||||
thread_id: String,
|
thread_id: String,
|
||||||
step: Option<String>,
|
step: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod shared_input_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn desktop() -> (ComputerRow, ScreenRow) {
|
||||||
|
let computer = ComputerRow {
|
||||||
|
id: "computer".into(),
|
||||||
|
space_id: "space".into(),
|
||||||
|
user_id: "user".into(),
|
||||||
|
scope: "team".into(),
|
||||||
|
scope_key: "team:space".into(),
|
||||||
|
home_key: "home".into(),
|
||||||
|
home_revision: "1".into(),
|
||||||
|
kind: "docker".into(),
|
||||||
|
provider_ref: Some("container".into()),
|
||||||
|
state: "running".into(),
|
||||||
|
control_holder: "bot".into(),
|
||||||
|
control_lease_id: None,
|
||||||
|
control_lease_expires_at: None,
|
||||||
|
control_bot_id: Some("bot".into()),
|
||||||
|
control_run_id: None,
|
||||||
|
execution_run_id: Some("run".into()),
|
||||||
|
execution_bot_id: Some("bot".into()),
|
||||||
|
execution_lease_expires_at: None,
|
||||||
|
execution_fence: 1,
|
||||||
|
browser_profile_mode: "per-bot".into(),
|
||||||
|
};
|
||||||
|
let screen = ScreenRow {
|
||||||
|
id: "screen".into(),
|
||||||
|
computer_id: computer.id.clone(),
|
||||||
|
bot_id: "bot".into(),
|
||||||
|
slot: 1,
|
||||||
|
display: ":1".into(),
|
||||||
|
view_port: 6080,
|
||||||
|
profile_mode: "per-bot".into(),
|
||||||
|
profile_path: "/tmp/profile".into(),
|
||||||
|
control_holder: "bot".into(),
|
||||||
|
control_lease_id: None,
|
||||||
|
control_lease_expires_at: None,
|
||||||
|
execution_run_id: Some("run".into()),
|
||||||
|
execution_lease_expires_at: None,
|
||||||
|
execution_fence: 1,
|
||||||
|
};
|
||||||
|
(computer, screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn human_input_does_not_require_or_change_the_agent_lease() {
|
||||||
|
let (computer, mut screen) = desktop();
|
||||||
|
for holder in ["bot", "none", "user"] {
|
||||||
|
screen.control_holder = holder.into();
|
||||||
|
assert!(user_can_interact(&computer, Some(&screen), "bot"));
|
||||||
|
assert_eq!(screen.execution_run_id.as_deref(), Some("run"));
|
||||||
|
assert!(status_from("bot", &computer, Some(&screen), None).shared_input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_input_requires_the_bots_own_live_screen() {
|
||||||
|
let (mut computer, mut screen) = desktop();
|
||||||
|
assert!(!user_can_interact(&computer, None, "bot"));
|
||||||
|
assert!(!user_can_interact(&computer, Some(&screen), "other-bot"));
|
||||||
|
screen.computer_id = "other-computer".into();
|
||||||
|
assert!(!user_can_interact(&computer, Some(&screen), "bot"));
|
||||||
|
screen.computer_id = computer.id.clone();
|
||||||
|
for state in ["stopped", "booting", "suspended", "error"] {
|
||||||
|
computer.state = state.into();
|
||||||
|
assert!(!user_can_interact(&computer, Some(&screen), "bot"));
|
||||||
|
}
|
||||||
|
computer.state = "running".into();
|
||||||
|
computer.provider_ref = None;
|
||||||
|
assert!(!user_can_interact(&computer, Some(&screen), "bot"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -648,7 +648,7 @@ async fn screen_url(
|
||||||
state.db.get_screen(&computer.id, &id).await.ok().flatten()
|
state.db.get_screen(&computer.id, &id).await.ok().flatten()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let interactive = computer::user_has_screen_control(&computer, screen.as_ref(), &id);
|
let interactive = computer::user_can_interact(&computer, screen.as_ref(), &id);
|
||||||
let _ = state
|
let _ = state
|
||||||
.sandbox
|
.sandbox
|
||||||
.connect_screen(
|
.connect_screen(
|
||||||
|
|
@ -728,7 +728,7 @@ async fn input(
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
.ok_or(StatusCode::NOT_FOUND)?;
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
let screen = state.db.get_screen(&computer.id, &id).await.ok().flatten();
|
let screen = state.db.get_screen(&computer.id, &id).await.ok().flatten();
|
||||||
if !computer::user_has_screen_control(&computer, screen.as_ref(), &id) {
|
if !computer::user_can_interact(&computer, screen.as_ref(), &id) {
|
||||||
return Err(StatusCode::CONFLICT);
|
return Err(StatusCode::CONFLICT);
|
||||||
}
|
}
|
||||||
let computer_ref = computer::computer_ref(&computer).ok_or(StatusCode::BAD_REQUEST)?;
|
let computer_ref = computer::computer_ref(&computer).ok_or(StatusCode::BAD_REQUEST)?;
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ Use tools only when the user wants something done on the computer: open a site,
|
||||||
|
|
||||||
The shell is one real terminal that stays open between calls: same directory, same exports, same background jobs. cd where the work is and stay there. A long-running job (server, build, download) comes back as status=running and keeps going — read it with log_lines, stop it with keys \"C-c\", and never type a second command into a terminal that is still busy.
|
The shell is one real terminal that stays open between calls: same directory, same exports, same background jobs. cd where the work is and stay there. A long-running job (server, build, download) comes back as status=running and keeps going — read it with log_lines, stop it with keys \"C-c\", and never type a second command into a terminal that is still busy.
|
||||||
|
|
||||||
When you ARE using the desktop: the human watches the same live screen. Only the latest screenshot you received is current; they may have interacted since. Call computer_observe before coordinate clicks, after navigation, when the outcome is uncertain, and before describing what is on screen. Never guess the screen state from files, history or memory. Never kill or restart the browser, display, or desktop processes; if the browser tool reports it is unavailable, use computer_observe / computer_act on the existing window instead.
|
When you ARE using the desktop: the human can interact with the same live screen while you work; this does not pause your task. Prefer browser/native element actions over moving the shared pointer. If the screen changes unexpectedly, observe again and continue from the current state; do not undo human changes or replay an uncertain click. Request human assistance only when the task needs it. Only the latest screenshot you received is current; they may have interacted since. Call computer_observe before coordinate clicks, after navigation, when the outcome is uncertain, and before describing what is on screen. Never guess the screen state from files, history or memory. Never kill or restart the browser, display, or desktop processes; if the browser tool reports it is unavailable, use computer_observe / computer_act on the existing window instead.
|
||||||
|
|
||||||
When you use the browser tool:
|
When you use the browser tool:
|
||||||
- snapshot first; click {\"action\":\"click\",\"element\":N}; type {\"action\":\"type\",\"element\":N,\"text\":\"...\"}; open a URL with navigate.
|
- snapshot first; click {\"action\":\"click\",\"element\":N}; type {\"action\":\"type\",\"element\":N,\"text\":\"...\"}; open a URL with navigate.
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ async fn upstream_target(
|
||||||
.sandbox
|
.sandbox
|
||||||
.connect_screen(
|
.connect_screen(
|
||||||
&computer_ref,
|
&computer_ref,
|
||||||
computer::user_has_screen_control(&computer, screen.as_ref(), bot_id),
|
computer::user_can_interact(&computer, screen.as_ref(), bot_id),
|
||||||
&computer::adapter_context_for(&actor, bot_id, "view", screen.as_ref(), None),
|
&computer::adapter_context_for(&actor, bot_id, "view", screen.as_ref(), None),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,7 @@ pub fn tool_definitions(memory_enabled: bool) -> Vec<ToolDefinition> {
|
||||||
},
|
},
|
||||||
ToolDefinition {
|
ToolDefinition {
|
||||||
name: "request_takeover".into(),
|
name: "request_takeover".into(),
|
||||||
description: "Ask the user to take over the live screen for passwords, 2FA, CAPTCHA, or a login wall when no saved account fits. Never ask them to paste secrets in chat. Prefer use_saved_login when list_accounts has a matching site.".into(),
|
description: "Pause the task and ask the user to complete passwords, 2FA, CAPTCHA, or a login wall when no saved account fits. The shared screen already accepts their input; they press Done, continue when finished. Never ask them to paste secrets in chat. Prefer use_saved_login when list_accounts has a matching site.".into(),
|
||||||
parameters: json!({
|
parameters: json!({
|
||||||
"type":"object",
|
"type":"object",
|
||||||
"properties":{
|
"properties":{
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,9 @@ pub struct ComputerStatus {
|
||||||
pub mode: ComputerMode,
|
pub mode: ComputerMode,
|
||||||
pub kind: SandboxKind,
|
pub kind: SandboxKind,
|
||||||
pub state: ComputerState,
|
pub state: ComputerState,
|
||||||
|
/// Human input is independent of the agent execution/pause lease.
|
||||||
|
#[serde(default)]
|
||||||
|
pub shared_input: bool,
|
||||||
pub control_holder: ControlHolder,
|
pub control_holder: ControlHolder,
|
||||||
pub control_bot_id: Option<String>,
|
pub control_bot_id: Option<String>,
|
||||||
pub takeover_requested: bool,
|
pub takeover_requested: bool,
|
||||||
|
|
|
||||||
|
|
@ -406,7 +406,7 @@ fn ref_kind(action: &serde_json::Map<String, Value>) -> String {
|
||||||
|
|
||||||
fn coordinate(value: Option<&Value>, name: &'static str) -> Result<u32, ActionError> {
|
fn coordinate(value: Option<&Value>, name: &'static str) -> Result<u32, ActionError> {
|
||||||
let number = value.and_then(Value::as_f64).unwrap_or(f64::NAN).round();
|
let number = value.and_then(Value::as_f64).unwrap_or(f64::NAN).round();
|
||||||
if !number.is_finite() || number < 0.0 || number > 100_000.0 {
|
if !number.is_finite() || !(0.0..=100_000.0).contains(&number) {
|
||||||
return Err(ActionError::BadCoordinate(name));
|
return Err(ActionError::BadCoordinate(name));
|
||||||
}
|
}
|
||||||
Ok(number as u32)
|
Ok(number as u32)
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,8 @@ fn decode_stdout(
|
||||||
let empty = Value::Null;
|
let empty = Value::Null;
|
||||||
let decoded = (!trimmed.is_empty())
|
let decoded = (!trimmed.is_empty())
|
||||||
.then(|| parse_jsonish(trimmed))
|
.then(|| parse_jsonish(trimmed))
|
||||||
.flatten();
|
.flatten()
|
||||||
|
.filter(Value::is_object);
|
||||||
let error = if !success || trimmed.starts_with('\u{274c}') || decoded.is_none() {
|
let error = if !success || trimmed.starts_with('\u{274c}') || decoded.is_none() {
|
||||||
Some(classify(&combined))
|
Some(classify(&combined))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -383,6 +384,11 @@ mod tests {
|
||||||
assert!(error.is_none());
|
assert!(error.is_none());
|
||||||
assert_eq!(value["width"], 1280);
|
assert_eq!(value["width"], 1280);
|
||||||
|
|
||||||
|
for malformed in ["null", "true", "42", "[]", r#""ok""#] {
|
||||||
|
let (_, error) = decode_stdout(malformed, "", true, classify);
|
||||||
|
assert!(error.is_some(), "non-object response accepted: {malformed}");
|
||||||
|
}
|
||||||
|
|
||||||
// Prose with a zero exit code used to be reported as success.
|
// Prose with a zero exit code used to be reported as success.
|
||||||
let (value, error) = decode_stdout("no window matched", "", true, classify);
|
let (value, error) = decode_stdout("no window matched", "", true, classify);
|
||||||
assert!(matches!(error, Some(ControlError::DriverUnhealthy)));
|
assert!(matches!(error, Some(ControlError::DriverUnhealthy)));
|
||||||
|
|
|
||||||
|
|
@ -172,9 +172,10 @@ impl ComputerController for CuaController {
|
||||||
}
|
}
|
||||||
let key = normalize_display(&ctx.display).to_string();
|
let key = normalize_display(&ctx.display).to_string();
|
||||||
let mut windows = self.windows(&ctx.display).await.unwrap_or_default();
|
let mut windows = self.windows(&ctx.display).await.unwrap_or_default();
|
||||||
let mut cache = self.browser.lock().await;
|
// The screen lock serializes this display. Never hold the shared cache
|
||||||
|
// lock across driver calls or waits on behalf of other displays.
|
||||||
|
let mut bind = self.browser.lock().await.remove(&key);
|
||||||
for attempt in 0..2 {
|
for attempt in 0..2 {
|
||||||
let mut bind = cache.remove(&key);
|
|
||||||
let had_bind = bind.is_some();
|
let had_bind = bind.is_some();
|
||||||
match browser::run(
|
match browser::run(
|
||||||
&self.client,
|
&self.client,
|
||||||
|
|
@ -204,20 +205,13 @@ impl ComputerController for CuaController {
|
||||||
| ControlError::TargetNotFound
|
| ControlError::TargetNotFound
|
||||||
) =>
|
) =>
|
||||||
{
|
{
|
||||||
|
bind = None;
|
||||||
windows = self.windows(&ctx.display).await.unwrap_or_default();
|
windows = self.windows(&ctx.display).await.unwrap_or_default();
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
if let Some(mut current) = bind {
|
if let Some(mut current) = bind {
|
||||||
current.page = match &other {
|
update_browser_page(&mut current, &request.action, &other);
|
||||||
Ok(page)
|
self.browser.lock().await.insert(key, current);
|
||||||
if page.ok
|
|
||||||
&& !matches!(request.action.as_str(), "probe" | "ensure") =>
|
|
||||||
{
|
|
||||||
Some(page.clone())
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
cache.insert(key, current);
|
|
||||||
}
|
}
|
||||||
return map_browser_unavailable(other);
|
return map_browser_unavailable(other);
|
||||||
}
|
}
|
||||||
|
|
@ -295,6 +289,22 @@ impl ComputerController for CuaController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn update_browser_page(
|
||||||
|
bind: &mut browser::BrowserBind,
|
||||||
|
action: &str,
|
||||||
|
result: &Result<CdpPage, ControlError>,
|
||||||
|
) {
|
||||||
|
// These checks neither observe nor mutate the page. Keep the refs returned
|
||||||
|
// by the previous snapshot usable for the next click/type.
|
||||||
|
if matches!(action, "probe" | "ensure") && result.is_ok() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bind.page = match result {
|
||||||
|
Ok(page) if page.ok => Some(page.clone()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
impl CuaController {
|
impl CuaController {
|
||||||
async fn run_actions(
|
async fn run_actions(
|
||||||
&self,
|
&self,
|
||||||
|
|
@ -737,6 +747,44 @@ fn observe_png_path(display: &str) -> PathBuf {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn browser_checks_preserve_refs_but_failed_actions_invalidate_them() {
|
||||||
|
let mut bind = browser::BrowserBind {
|
||||||
|
pid: 1,
|
||||||
|
window_id: 2,
|
||||||
|
target_id: "target".into(),
|
||||||
|
tab_id: "tab".into(),
|
||||||
|
page: Some(CdpPage {
|
||||||
|
ok: true,
|
||||||
|
title: "original snapshot".into(),
|
||||||
|
..CdpPage::default()
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
for action in ["probe", "ensure"] {
|
||||||
|
update_browser_page(
|
||||||
|
&mut bind,
|
||||||
|
action,
|
||||||
|
&Ok(CdpPage {
|
||||||
|
ok: true,
|
||||||
|
..CdpPage::default()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert_eq!(bind.page.as_ref().unwrap().title, "original snapshot");
|
||||||
|
}
|
||||||
|
update_browser_page(&mut bind, "click", &Err(ControlError::StaleReference));
|
||||||
|
assert!(bind.page.is_none());
|
||||||
|
update_browser_page(
|
||||||
|
&mut bind,
|
||||||
|
"snapshot",
|
||||||
|
&Ok(CdpPage {
|
||||||
|
ok: true,
|
||||||
|
title: "fresh".into(),
|
||||||
|
..CdpPage::default()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert_eq!(bind.page.as_ref().unwrap().title, "fresh");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn driver_release_reads_the_pinned_minor_series() {
|
fn driver_release_reads_the_pinned_minor_series() {
|
||||||
assert_eq!(driver_release("cua-driver 0.23.2"), Some((0, 23)));
|
assert_eq!(driver_release("cua-driver 0.23.2"), Some((0, 23)));
|
||||||
|
|
|
||||||
|
|
@ -99,26 +99,28 @@ fn translate_pointer(
|
||||||
}
|
}
|
||||||
|
|
||||||
fn translate_key(key: &str, modifiers: Option<&[String]>) -> TranslatedAction {
|
fn translate_key(key: &str, modifiers: Option<&[String]>) -> TranslatedAction {
|
||||||
let key = map_key(key);
|
// The public action DSL and mobile keyboard send chords as "ctrl+a".
|
||||||
match modifiers {
|
// Cua requires separate keys passed to hotkey, not a literal press_key.
|
||||||
Some(items) if !items.is_empty() => {
|
let mut keys: Vec<String> = modifiers
|
||||||
let mut keys: Vec<String> = items.iter().map(|item| map_key(item)).collect();
|
.unwrap_or_default()
|
||||||
keys.push(key);
|
.iter()
|
||||||
|
.map(|item| map_key(item))
|
||||||
|
.collect();
|
||||||
|
if key.contains('+') && key.split('+').all(|part| !part.is_empty()) {
|
||||||
|
keys.extend(key.split('+').map(map_key));
|
||||||
|
} else {
|
||||||
|
keys.push(map_key(key));
|
||||||
|
}
|
||||||
|
if keys.len() > 1 {
|
||||||
TranslatedAction::Cua {
|
TranslatedAction::Cua {
|
||||||
tool: "hotkey",
|
tool: "hotkey",
|
||||||
payload: json!({
|
payload: json!({ "keys": keys, "scope": "desktop" }),
|
||||||
"keys": keys,
|
|
||||||
"scope": "desktop",
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
_ => TranslatedAction::Cua {
|
TranslatedAction::Cua {
|
||||||
tool: "press_key",
|
tool: "press_key",
|
||||||
payload: json!({
|
payload: json!({ "key": keys[0], "scope": "desktop" }),
|
||||||
"key": key,
|
}
|
||||||
"scope": "desktop",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,6 +140,26 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use lazyboy_contracts::RefVerb;
|
use lazyboy_contracts::RefVerb;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inline_shortcuts_use_hotkey_and_literal_plus_stays_a_key() {
|
||||||
|
for (key, expected) in [
|
||||||
|
("ctrl+a", json!(["ctrl", "a"])),
|
||||||
|
("alt+Left", json!(["alt", "left"])),
|
||||||
|
("Control+Shift+Tab", json!(["ctrl", "shift", "tab"])),
|
||||||
|
] {
|
||||||
|
let TranslatedAction::Cua { tool, payload } = translate_key(key, None) else {
|
||||||
|
panic!("expected Cua")
|
||||||
|
};
|
||||||
|
assert_eq!(tool, "hotkey");
|
||||||
|
assert_eq!(payload["keys"], expected);
|
||||||
|
}
|
||||||
|
let TranslatedAction::Cua { tool, payload } = translate_key("+", None) else {
|
||||||
|
panic!("expected Cua")
|
||||||
|
};
|
||||||
|
assert_eq!(tool, "press_key");
|
||||||
|
assert_eq!(payload["key"], "+");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pixel_click_is_desktop_cua_click() {
|
fn pixel_click_is_desktop_cua_click() {
|
||||||
let action = ComputerAction::Pointer {
|
let action = ComputerAction::Pointer {
|
||||||
|
|
|
||||||
|
|
@ -518,11 +518,15 @@ test('only a human moves the mouse, and the veil never holds it back',()=>{
|
||||||
for(const holder of ['none','bot'])assert.equal(viewOnlyFor(holder),true,holder);
|
for(const holder of ['none','bot'])assert.equal(viewOnlyFor(holder),true,holder);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shared input stays interactive across agent ownership and pause states',()=>{
|
||||||
|
for(const holder of ['none','bot','user'])assert.equal(viewOnlyFor(holder,true),false,holder);
|
||||||
|
});
|
||||||
|
|
||||||
test('taking the screen flips the mouse on the click, not on the reply',()=>{
|
test('taking the screen flips the mouse on the click, not on the reply',()=>{
|
||||||
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
|
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
|
||||||
const body=app.slice(app.indexOf('async function setControl('));
|
const body=app.slice(app.indexOf('async function setControl('));
|
||||||
const flip=body.indexOf('controlHolder:holder');
|
const flip=body.indexOf('controlHolder:holder');
|
||||||
const gate=body.indexOf('pushViewOnly(viewOnlyFor(holder))');
|
const gate=body.indexOf('pushViewOnly(viewOnlyFor(holder,computer.sharedInput))');
|
||||||
const post=body.indexOf('await api(`/api/computer/${botId}/${holder==="user"?"takeover":"release"}`');
|
const post=body.indexOf('await api(`/api/computer/${botId}/${holder==="user"?"takeover":"release"}`');
|
||||||
const readBack=body.indexOf('finally{');
|
const readBack=body.indexOf('finally{');
|
||||||
assert.ok(flip>0&&gate>0&&post>0&&readBack>0,'optimistic flip, gate, request, and read back all present');
|
assert.ok(flip>0&&gate>0&&post>0&&readBack>0,'optimistic flip, gate, request, and read back all present');
|
||||||
|
|
@ -530,15 +534,27 @@ test('taking the screen flips the mouse on the click, not on the reply',()=>{
|
||||||
assert.ok(readBack>post,'the truth is read back after the request');
|
assert.ok(readBack>post,'the truth is read back after the request');
|
||||||
assert.match(body.slice(readBack),/await refresh\(\)/);
|
assert.match(body.slice(readBack),/await refresh\(\)/);
|
||||||
assert.match(body,/if\(!botId\|\|controlBusyRef\.current\)return/,'a double click cannot fight itself');
|
assert.match(body,/if\(!botId\|\|controlBusyRef\.current\)return/,'a double click cannot fight itself');
|
||||||
assert.match(app,/const listener=\(event:MessageEvent\)=>\{[\s\S]*?lazyboy-request-control[\s\S]*?void setControl\("user"\)/);
|
assert.match(app,/if\(computer\.sharedInput\)\{pushViewOnly\(!desktopInteractive\);return\}void setControl\("user"\)/);
|
||||||
assert.match(app,/if\(expectedHolderRef\.current&&status\.controlHolder===expectedHolderRef\.current\)/,'agreement, not a timer, ends the handoff');
|
assert.match(app,/if\(expectedHolderRef\.current&&status\.controlHolder===expectedHolderRef\.current\)/,'agreement, not a timer, ends the handoff');
|
||||||
assert.match(app,/onClick=\{onTakeOver\}/);
|
assert.match(app,/onClick=\{onTakeOver\}/);
|
||||||
assert.match(app,/onClick=\{onRelease\}/);
|
assert.match(app,/onClick=\{onRelease\}/);
|
||||||
assert.doesNotMatch(app,/action\(\(\)=>api\(`\/api\/computer\/[^`]*takeover/,'takeover no longer rides the global busy path');
|
assert.doesNotMatch(app,/action\(\(\)=>api\(`\/api\/computer\/[^`]*takeover/,'takeover no longer rides the global busy path');
|
||||||
assert.equal((app.match(/\/api\/computer\/\$\{[^}]*\}\/(takeover|release)/g)||[]).length,0,'every handoff goes through setControl');
|
assert.equal((app.match(/\/api\/computer\/\$\{[^}]*\}\/(takeover|release)/g)||[]).length,0,'every handoff goes through setControl');
|
||||||
assert.equal((app.match(/holder==="user"\?"takeover":"release"/g)||[]).length,1,'one request path, one owner of it');
|
assert.equal((app.match(/holder==="user"\?"takeover":"release"/g)||[]).length,1,'one request path, one owner of it');
|
||||||
assert.match(app,/void setControl\("user",active\.id\)/,'the call overlay hands over the same way');
|
});
|
||||||
assert.match(app,/await setControl\("user",id\)/,'the login screen boots, then takes the mouse without a second spinner');
|
|
||||||
|
test('shared CUA desktops hide takeover and keep the mouse live',()=>{
|
||||||
|
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
|
||||||
|
assert.match(app,/sharedInput:true/,'the first paint already assumes shared input');
|
||||||
|
assert.match(app,/if\(!computer\.sharedInput\)await setControl\("user",id\)/,'opening the login screen must not pause a shared run');
|
||||||
|
assert.match(app,/if\(active&&!computer\.sharedInput\)void setControl\("user",active\.id\)/,'a call only takes exclusive control on older exclusive desktops');
|
||||||
|
assert.match(app,/computer\.sharedInput\?\(computer\.takeoverRequested\|\|computer\.controlHolder==="user"\?<button className="primary" disabled=\{busy\|\|pending\} onClick=\{onRelease\}>\{t\("doneContinue"\)\}<\/button>:null\)/);
|
||||||
|
assert.match(app,/computer\.sharedInput\?t\("sharedDesktop"\)/);
|
||||||
|
assert.match(app,/computer\.sharedInput\?t\("sharedNeedsUser"/);
|
||||||
|
assert.match(app,/takeover=\{computer\.takeoverRequested\|\|\(!computer\.sharedInput&&computer\.controlHolder==="user"\)\}/);
|
||||||
|
const vnc=fs.readFileSync('apps/web/vnc.html','utf8');
|
||||||
|
assert.match(vnc,/rfb\.viewOnly = true;/,'a dropped RFB is muted locally');
|
||||||
|
assert.doesNotMatch(vnc,/disconnect[\s\S]{0,400}applyViewOnly\(true\)/,'disconnect must not overwrite host intent');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the waiting veil covers the desktop the way it always did',()=>{
|
test('the waiting veil covers the desktop the way it always did',()=>{
|
||||||
|
|
@ -587,7 +603,7 @@ test('a desktop that is still starting is dialled again fast, a dropped session
|
||||||
assert.equal(scheduled.at(-1),1500,'a session that was really there is retried calmly');
|
assert.equal(scheduled.at(-1),1500,'a session that was really there is retried calmly');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a session dialed after a handoff starts with the mouse already handed over',()=>{
|
for(const hostUpdatesDuringReconnect of [false,true])test(`reconnect preserves shared input (host update during reconnect: ${hostUpdatesDuringReconnect})`,()=>{
|
||||||
const vnc=fs.readFileSync('apps/web/vnc.html','utf8');
|
const vnc=fs.readFileSync('apps/web/vnc.html','utf8');
|
||||||
// Run the viewer rather than read it. The host hands the mouse over while the
|
// Run the viewer rather than read it. The host hands the mouse over while the
|
||||||
// viewer sits between two sessions: exactly the moment a fresh RFB used to
|
// viewer sits between two sessions: exactly the moment a fresh RFB used to
|
||||||
|
|
@ -601,9 +617,11 @@ test('a session dialed after a handoff starts with the mouse already handed over
|
||||||
const window={location:{pathname:'/vnc.html',protocol:'http:',host:'localhost',origin:'http://localhost',hash:''},parent,addEventListener(name,handler){if(name==='message')messages.push(handler);}};
|
const window={location:{pathname:'/vnc.html',protocol:'http:',host:'localhost',origin:'http://localhost',hash:''},parent,addEventListener(name,handler){if(name==='message')messages.push(handler);}};
|
||||||
vm.runInNewContext(body,{window,document:{location:{href:'http://localhost/vnc.html'},getElementById:()=>element(),querySelector:()=>null},navigator:{clipboard:{}},RFB,setTimeout:(fn)=>{scheduled.push(fn);return scheduled.length},clearTimeout(){}});
|
vm.runInNewContext(body,{window,document:{location:{href:'http://localhost/vnc.html'},getElementById:()=>element(),querySelector:()=>null},navigator:{clipboard:{}},RFB,setTimeout:(fn)=>{scheduled.push(fn);return scheduled.length},clearTimeout(){}});
|
||||||
const fire=(name,event)=>instances.forEach(rfb=>(rfb._handlers[name]||[]).forEach(handler=>handler(event||{})));
|
const fire=(name,event)=>instances.forEach(rfb=>(rfb._handlers[name]||[]).forEach(handler=>handler(event||{})));
|
||||||
fire('disconnect',{clean:false});
|
|
||||||
assert.ok(messages.length,'the viewer listens for the host gate');
|
assert.ok(messages.length,'the viewer listens for the host gate');
|
||||||
messages.forEach(handler=>handler({origin:'http://localhost',source:parent,data:{type:'lazyboy-view-only',viewOnly:false}}));
|
const allowInput=()=>messages.forEach(handler=>handler({origin:'http://localhost',source:parent,data:{type:'lazyboy-view-only',viewOnly:false}}));
|
||||||
|
allowInput();
|
||||||
|
fire('disconnect',{clean:false});
|
||||||
|
if(hostUpdatesDuringReconnect)allowInput();
|
||||||
scheduled.at(-1)();
|
scheduled.at(-1)();
|
||||||
fire('connect');
|
fire('connect');
|
||||||
assert.equal(instances.length,2,'the retry dialed a new session');
|
assert.equal(instances.length,2,'the retry dialed a new session');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue