Skip to content

feat(agent): add a hardware-free mock agent for GUI development - #568

Open
AprilNEA wants to merge 7 commits into
masterfrom
feat/mock-agent
Open

feat(agent): add a hardware-free mock agent for GUI development#568
AprilNEA wants to merge 7 commits into
masterfrom
feat/mock-agent

Conversation

@AprilNEA

@AprilNEA AprilNEA commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Summary

Adds openlogi-agent-mock, a dev-only binary that serves the real Agent tarpc
contract 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-dev profile — the one the dev app bundle
already 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 xtask bundles openlogi-agent by exact path, so it can
never ship.

Changes

  • openlogi-agent: new openlogi-agent-mock bin (src/bin/mock_agent.rs).
    • Scripts a Bolt receiver with an online mouse (buttons, pointer, hi-res wheel,
      thumbwheel), an offline mouse (no capabilities — exercises the
      presumed_from_kind fallback) and a lighting-capable keyboard, plus a
      directly-attached mouse and a standalone Litra Glow: both DeviceRoute kinds,
      every capability-gated panel, and the standalone-light surface.
    • DPI and SmartShift writes persist in memory and read back; devices without a
      feature answer FeatureUnsupported and unknown routes DeviceNotFound, so the
      GUI's permanent-vs-transient error handling is exercised.
    • The online mouse's battery drains ~1%/minute and camera_active flips every
      30s, making poll-driven repaints and camera-linked light rendering observable.
    • start_pairing runs a scripted Bolt session (Searching → DeviceFound →
      Passkey → Paired) and the paired device joins the inventory.
    • Holds that profile's agent.lock and answers the current PROTOCOL_VERSION,
      so a real agent spawned meanwhile exits as a duplicate and a release agent's
      takeover handshake stands down. agent_version carries a -mock suffix so a
      mock session is identifiable in the UI.
  • docs: run recipe and the two runner flags it needs in docs/DEVELOPMENT.md;
    pointer in AGENTS.md's build/run section.
  • style: strips a trailing blank line from Cargo.toml and
    third_party/tarpc/Cargo.toml — the end-of-file-fixer hook rewrites both on
    every 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_VERSION is untouched.

Testing

  • devenv tasks run openlogi:check (fmt + clippy -D warnings + workspace tests)
    — passes.
  • Ran the dev GUI against the mock end to end. It connects on the dev socket,
    decodes snapshots, and resolves a real asset depot for every scripted device
    (mx_master_3s, mx_keys, mx_vertical, litra_glow). The installed
    production agent kept running throughout, untouched.
  • Drove every RPC over the socket with a throwaway tarpc client (not committed):
    handshake at v13, snapshot, set_dpi(3000)read_dpi reads back 3000,
    read_smartshift, FeatureUnsupported on the keyboard, DeviceNotFound on an
    unknown route, set_light / set_light_manual_power accepted for the light and
    rejected for a wrong route, and the pairing flow through Paired { slot: 4 }
    with the device appearing in the next snapshot.
  • Not hardware-verified, and deliberately so — this changes no device code path.
    Device-card visuals were not screenshotted (this dev host has no screen-recording
    permission); the panels are driven by the scripted Capabilities verified above.

Note

Building this surfaced #571: StandaloneDevice::registry_model_id is
skip_serializing_if, which truncates the bincode stream when it is None and
makes the entire snapshot undecodable by the GUI. The mock therefore always sends
a Some(...). Not fixed here — it needs a PROTOCOL_VERSION bump and golden
regeneration of its own.

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-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown

Greptile Summary

Adds a development-only mock agent that implements the real IPC contract using scripted devices and pairing behavior, allowing GUI development without Logitech hardware.

  • Registers the openlogi-agent-mock binary and documents its development workflow.
  • Provides in-memory device settings, inventory snapshots, standalone lighting, and timed state changes.
  • Scripts a session-identified Bolt pairing flow that prevents cancelled tasks from mutating replacement sessions.
  • Leaves production packaging and the IPC protocol unchanged.

Confidence Score: 5/5

The 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.

Important Files Changed

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)
Loading

Reviews (4): Last reviewed commit: "fix(agent): drop the mock's passkey prom..." | Re-trigger Greptile

Comment thread crates/openlogi-agent/src/bin/mock_agent.rs Outdated
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.
Comment thread crates/openlogi-agent/src/bin/mock_agent.rs Outdated
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.
Copilot AI lite review requested due to automatic review settings August 11, 2026 10:24

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 the Agent service with scripted inventory + pairing flow.
  • Documents how to run the GUI against the mock agent in docs/DEVELOPMENT.md and adds a pointer in AGENTS.md.
  • Removes trailing blank lines in workspace Cargo.toml and third_party/tarpc/Cargo.toml to 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.

Comment on lines +781 to +797
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 });
});

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +161 to +167
#[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");
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants