feat(agent): add a hardware-free mock agent for GUI development - #568
feat(agent): add a hardware-free mock agent for GUI development#568AprilNEA wants to merge 7 commits into
Conversation
openlogi-agent-mock serves the real Agent tarpc service on the real IPC socket from a scripted in-memory inventory: an online mouse (DPI + SmartShift state that persists and reads back, battery draining ~1%/minute so poll-driven repaints are visible), an offline mouse, a lighting-capable keyboard, and one directly-attached device — covering every capability-gated panel and both route kinds without hardware. start_pairing runs a scripted Bolt flow (discovery, passkey, paired) and the paired device joins the inventory. The mock holds agent.lock, so real agents spawned meanwhile (GUI auto-spawn, launchd) exit as duplicates; it answers the current PROTOCOL_VERSION so a release agent's takeover handshake stands down. Never bundled: xtask copies openlogi-agent by exact path.
Greptile SummaryAdds a development-only mock agent that implements the real IPC contract using scripted devices and pairing behavior, allowing GUI development without Logitech hardware.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains; the previously reported stale pairing tasks now verify their originating session before discovering or completing a device.
|
| Filename | Overview |
|---|---|
| crates/openlogi-agent/src/bin/mock_agent.rs | Implements the mock IPC agent, scripted inventories and settings, standalone lighting, and session-scoped pairing lifecycle. |
| crates/openlogi-agent/Cargo.toml | Registers the mock as a separate development binary without changing the production agent target. |
| docs/DEVELOPMENT.md | Documents how to run the mock with the development GUI and explains profile and lock behavior. |
| AGENTS.md | Adds a concise pointer to the hardware-free GUI development workflow. |
Sequence Diagram
sequenceDiagram
participant GUI
participant Mock as Mock Agent
participant State as Pairing State
GUI->>Mock: start_pairing()
Mock->>State: begin_pairing(session id)
Mock-->>GUI: Searching
Mock-->>GUI: DeviceFound
GUI->>Mock: pair_device(address)
Mock->>State: verify active session id
Mock-->>GUI: Passkey
Mock->>State: end matching session
Mock->>State: append paired device
Mock-->>GUI: Paired(slot)
Reviews (4): Last reviewed commit: "fix(agent): drop the mock's passkey prom..." | Re-trigger Greptile
Adds the run recipe and its constraints (real agent must be stopped; they share agent.lock) to the developer handbook, and a pointer in the agent guide's build/run section.
next_pairing awaited the receiver while holding its lock, so a start_pairing arriving mid-hold waited out the poll; it now polls at a 100ms tick and holds the lock only for the try_recv. Rebuilds the scripted inventory per poll (the mouse battery is the one field that moves) instead of patching a stored template, which drops the by-uid lookup and the slot-0 fallback pairing had when that lookup could miss.
Brings the mock up to the current contract: the two standalone-light RPCs, the snapshot's standalone list and camera_active, and the thumbwheel capability. Adds a scripted Litra Glow (capabilities the light panel gates on, writes accepted, unknown routes rejected) and a camera_active flag that flips on a timer so camera-linked rendering has an input to follow. The mock now claims the openlogi-dev profile unless OPENLOGI_PROFILE says otherwise. Dev bundles moved to their own socket, so a production-profile mock would never meet the dev GUI — and would sit on the installed app's paths instead. Its light keeps a Some(registry_model_id): #571 makes a None truncate the bincode stream and the whole snapshot undecodable.
The end-of-file-fixer hook rewrites both on every push, which blocks the push until they are committed. Byte-for-byte what the hook produces.
deb8d32 to
3c7253d
Compare
A pairing session's spawned tasks sleep across a cancel, so a session started right after one could be corrupted by its predecessor: the stale discovery task handed the replacement a device it never searched for, and the stale pair task consumed the replacement outright — adding the cancelled device and sending Paired through its own dropped channel, so the GUI saw neither the pairing nor a live session. Sessions now carry an id and both tasks act only while it is still the live one. Cancelling with nothing active also returns Ok, matching PairingManager::cancel — the mock must not hand the GUI a contract the real agent does not have. Verified against a running mock: cancel-then-restart inside the discovery delay yields exactly one DeviceFound (the new session's own), and inside the passkey wait yields no Paired, an unchanged inventory, and a session that is still cancellable.
There was a problem hiding this comment.
Pull request overview
Adds a dev-only openlogi-agent-mock binary in crates/openlogi-agent that serves the real tarpc Agent IPC contract from a scripted, in-memory inventory so the GUI can be developed and exercised without any attached Logitech hardware.
Changes:
- Introduces
openlogi-agent-mock(new bin target) implementing theAgentservice with scripted inventory + pairing flow. - Documents how to run the GUI against the mock agent in
docs/DEVELOPMENT.mdand adds a pointer inAGENTS.md. - Removes trailing blank lines in workspace
Cargo.tomlandthird_party/tarpc/Cargo.tomlto satisfy formatting hooks.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/openlogi-agent/src/bin/mock_agent.rs | New mock agent binary implementing the IPC contract with scripted inventory/pairing. |
| crates/openlogi-agent/Cargo.toml | Registers the new openlogi-agent-mock binary target. |
| docs/DEVELOPMENT.md | Adds a “no hardware” GUI development run recipe using the mock agent. |
| AGENTS.md | Adds a short pointer to the mock-agent workflow for GUI development. |
| Cargo.toml | Trims a trailing blank line to satisfy formatting hooks. |
| third_party/tarpc/Cargo.toml | Trims a trailing blank line to satisfy formatting hooks. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| let state = Arc::clone(&self.state); | ||
| tokio::spawn(async move { | ||
| tokio::time::sleep(PASSKEY_DELAY).await; | ||
| let _ = tx.send(PairingUpdate::Passkey(PasskeyMethod::Keyboard( | ||
| "482913".to_string(), | ||
| ))); | ||
| tokio::time::sleep(PASSKEY_TYPING_DELAY).await; | ||
| let mut state = state.lock().await; | ||
| // No session of ours left = cancelled while the "user" was typing. | ||
| // Ending it by id also means a session started in the meantime is | ||
| // neither consumed here nor completed with our (dropped) channel. | ||
| if state.end_pairing(id).is_none() { | ||
| return; | ||
| } | ||
| let slot = state.pair_scripted(&name); | ||
| let _ = tx.send(PairingUpdate::Paired { slot }); | ||
| }); |
There was a problem hiding this comment.
Right, and it mattered more than it looks: only start_pairing swaps the long-poll receiver, so after a plain cancel the GUI is still polling that same channel and would have seen a passkey prompt arrive behind Failed(Cancelled). Fixed in 3dfe1f4 — the task now checks it is still the live session before sending.
Verified against a running mock: cancelling 400ms into the 800ms delay yields only Failed(Cancelled), with no prompt behind it.
| #[expect( | ||
| unsafe_code, | ||
| reason = "the profile must be chosen before openlogi_core::paths caches it, and only a process-wide env var selects it" | ||
| )] | ||
| unsafe { | ||
| std::env::set_var("OPENLOGI_PROFILE", "dev"); | ||
| } |
There was a problem hiding this comment.
std::env::set_var is not safe here — it became unsafe in Rust 2024, which this workspace uses. Without the block it does not compile, and if the block were redundant the unused_unsafe warning would fail the build under -D warnings.
The env var is also the only mechanism available: openlogi_core::paths selects the profile from OPENLOGI_PROFILE (or a .dev bundle id, which a bare cargo run binary does not have) and caches the answer in a OnceLock. Setting it as the first statement of main — before any runtime, subscriber or thread exists — is exactly the condition that makes the call sound, which is what the SAFETY comment records.
A plain cancel leaves the GUI polling the same channel (only start_pairing swaps the receiver), so the stale task's Passkey arrived after Failed(Cancelled). The task now checks it is still the live session first. Verified: cancelling 400ms into the 800ms passkey delay yields only Failed(Cancelled), no prompt behind it, and an unchanged inventory.
Summary
Adds
openlogi-agent-mock, a dev-only binary that serves the realAgenttarpccontract from a scripted in-memory inventory. The GUI runs unmodified against it —
no device, no receiver, no Accessibility grant — which unblocks UI work on machines
with no hardware attached and makes states that are awkward to reproduce on real
devices (offline device, unsupported feature, a full pairing flow, a standalone
light) reachable on demand.
It defaults itself to the
openlogi-devprofile — the one the dev app bundlealready uses — so it meets the dev GUI on the dev socket and the installed
production app keeps running untouched.
The mock is a separate binary rather than a flag on the real agent: no production
code path changes, and
xtaskbundlesopenlogi-agentby exact path, so it cannever ship.
Changes
openlogi-agent: newopenlogi-agent-mockbin (src/bin/mock_agent.rs).thumbwheel), an offline mouse (no capabilities — exercises the
presumed_from_kindfallback) and a lighting-capable keyboard, plus adirectly-attached mouse and a standalone Litra Glow: both
DeviceRoutekinds,every capability-gated panel, and the standalone-light surface.
feature answer
FeatureUnsupportedand unknown routesDeviceNotFound, so theGUI's permanent-vs-transient error handling is exercised.
camera_activeflips every30s, making poll-driven repaints and camera-linked light rendering observable.
start_pairingruns a scripted Bolt session (Searching → DeviceFound →Passkey → Paired) and the paired device joins the inventory.
agent.lockand answers the currentPROTOCOL_VERSION,so a real agent spawned meanwhile exits as a duplicate and a release agent's
takeover handshake stands down.
agent_versioncarries a-mocksuffix so amock session is identifiable in the UI.
docs/DEVELOPMENT.md;pointer in
AGENTS.md's build/run section.Cargo.tomlandthird_party/tarpc/Cargo.toml— theend-of-file-fixerhook rewrites both onevery push, so pushes are blocked until they are committed. Unrelated to the
mock; happy to split it out.
No changes to the IPC contract —
PROTOCOL_VERSIONis untouched.Testing
devenv tasks run openlogi:check(fmt + clippy-D warnings+ workspace tests)— passes.
decodes snapshots, and resolves a real asset depot for every scripted device
(
mx_master_3s,mx_keys,mx_vertical,litra_glow). The installedproduction agent kept running throughout, untouched.
handshake at v13, snapshot,
set_dpi(3000)→read_dpireads back 3000,read_smartshift,FeatureUnsupportedon the keyboard,DeviceNotFoundon anunknown route,
set_light/set_light_manual_poweraccepted for the light andrejected for a wrong route, and the pairing flow through
Paired { slot: 4 }with the device appearing in the next snapshot.
Device-card visuals were not screenshotted (this dev host has no screen-recording
permission); the panels are driven by the scripted
Capabilitiesverified above.Note
Building this surfaced #571:
StandaloneDevice::registry_model_idisskip_serializing_if, which truncates the bincode stream when it isNoneandmakes the entire snapshot undecodable by the GUI. The mock therefore always sends
a
Some(...). Not fixed here — it needs aPROTOCOL_VERSIONbump and goldenregeneration of its own.