Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
9b8591d
wip: GitHub watch agent tool design draft, pending trust decision and…
andrebrait Nov 13, 2026
6192000
wip: GitHub agent tools design: read/reply tools, outbox delivery, me…
andrebrait Nov 13, 2026
470cdee
docs: approve GitHub agent tools design; implementation plan
andrebrait Nov 13, 2026
c35f0f4
feat(github): gh runner and target parsing
andrebrait Nov 13, 2026
7b1eb72
docs(github): availability check is gh api user
andrebrait Nov 13, 2026
d47337b
feat(github): snapshot, read and write calls through gh
andrebrait Nov 13, 2026
60a233c
feat(github): watch events and metadata-only update text
andrebrait Nov 13, 2026
b77d74d
fix(github): never trim ids the snapshot still returns
andrebrait Nov 13, 2026
6607e11
feat(github): SQLite watch store
andrebrait Nov 13, 2026
936eac0
fix(github): guard pending rows and test session moves
andrebrait Nov 13, 2026
134e9ae
feat(github): poll watches and deliver through the outbox
andrebrait Nov 13, 2026
0f39382
fix(github): reachable gh outage notices; guard the poll cycle and mo…
andrebrait Nov 13, 2026
0051c71
feat(github): watch, read and reply agent tools
andrebrait Nov 13, 2026
736b089
fix(github): break the tools import cycle; validate replyTo before as…
andrebrait Nov 13, 2026
2c56b6c
feat(github): setting to allow agent writes without asking; docs
andrebrait Nov 13, 2026
f4e19e8
docs(github): correct agent tools notes
andrebrait Nov 13, 2026
9b2f1c1
fix(github): writes check and name where they land; testable gating
andrebrait Nov 13, 2026
5ecbbcf
fix(github): poller holds items during own posts, keeps a GraphQL bud…
andrebrait Nov 13, 2026
67dba85
fix(github): log a failed watch cleanup on session delete; document g…
andrebrait Nov 13, 2026
5bf2c1b
docs(github): spec matches the poll budget
andrebrait Nov 13, 2026
19458c0
fix(github): CI updates carry counts, not fork-controlled check names
andrebrait Nov 13, 2026
411012b
fix(github): a re-watch gets its own update entry ids
andrebrait Nov 13, 2026
b094660
fix(settings): GitHub writes switch stays unknown after a failed load…
andrebrait Nov 13, 2026
55c6bbd
fix(github): cancelled writes post nothing; a failed post GitHub took…
andrebrait Nov 13, 2026
9fd5630
test(github): store tests check each watch's registration time
andrebrait Nov 13, 2026
7b0fa5f
fix(github): updates carry a thread's line, not its fork-controlled f…
andrebrait Nov 13, 2026
7fce145
fix(github): a remote session's comment needs its target's read grant
andrebrait Nov 13, 2026
7401776
fix(github): a session delete or move creates no watch store
andrebrait Nov 13, 2026
2d90bf3
test(github): replay test sends omp's channel frames
andrebrait Nov 14, 2026
5fb5370
docs(github): one changelog entry; name the allowAgentStart exception
andrebrait Nov 14, 2026
d5eb5af
fix(github): bind runner to stored github.com login
andrebrait Nov 14, 2026
53fc49b
fix: abort server tools before child teardown
andrebrait Nov 14, 2026
71d4f59
test(github): support pinned host in lifecycle CLI fixture
andrebrait Nov 14, 2026
c4ffa37
fix(settings): reconcile uncertain GitHub write saves
andrebrait Nov 14, 2026
c9363e5
fix(github): paginate review thread replies
andrebrait Nov 14, 2026
c538b78
fix(github): resolve review replies to root IDs
andrebrait Nov 14, 2026
0cee797
fix(github): suppress reviews created by replies
andrebrait Nov 14, 2026
d089e26
fix(github): fence and batch watch updates
andrebrait Nov 14, 2026
0187441
test(github): align watcher fixture with nested pagination
andrebrait Nov 14, 2026
f7beedc
docs(github): describe fenced batches and exact own-post identities
andrebrait Nov 14, 2026
4758095
fix(github): cancel watch registration after snapshot
andrebrait Nov 14, 2026
de64ee1
fix: guard server tools across restarts and session moves
andrebrait Nov 14, 2026
0356ce9
fix(outbox): retain receipts while GitHub batches remain pending
andrebrait Nov 14, 2026
0f4fc5d
fix(outbox): release batch receipt pins by stable entry identity
andrebrait Nov 14, 2026
dd88703
fix(github): guard pagination cursor progress
andrebrait Nov 14, 2026
efa9199
fix(github): retain review history and legacy pending
andrebrait Nov 14, 2026
6c7c68c
test(github): cover retained watcher histories
andrebrait Nov 14, 2026
f3d26d2
fix(github): track rate limits across snapshot pages
andrebrait Nov 14, 2026
99f9ceb
fix(settings): read watcher gate from disk
andrebrait Nov 14, 2026
28d5c26
fix(github): pin pending receipts through batch ack
andrebrait Nov 14, 2026
b621750
fix(github): reconcile pins in production cycles
andrebrait Nov 14, 2026
dcefa7b
fix(github): snapshot active batch receipts atomically
andrebrait Nov 14, 2026
64e57b4
fix: abort server tools on conversation switch
andrebrait Nov 14, 2026
7de98d4
docs(github): keep CI updates metadata-only
andrebrait Nov 14, 2026
b33055c
fix(github): report unknown comment write outcomes
andrebrait Nov 14, 2026
71b67d3
fix(github): fence in-flight watch registrations
andrebrait Nov 14, 2026
f357c96
fix(settings): reconcile displayed permissions from current disk state
andrebrait Nov 14, 2026
5a47ad2
test(github): exercise production watch move subscription
andrebrait Nov 14, 2026
6d7595f
docs(github): align pagination, receipt pins and localized prerequisites
andrebrait Nov 14, 2026
2ea088e
fix(github): page submitted reviews beyond known IDs
andrebrait Nov 14, 2026
1e600d2
test(github): probe Windows case-insensitive token inheritance
andrebrait Nov 14, 2026
10bd8a1
fix(github): enforce pause at durable outbox admission
andrebrait Nov 14, 2026
b7774a5
fix: route restart tool admission by child
andrebrait Nov 14, 2026
64ee7ec
fix(settings): serialize cross-process setting updates
andrebrait Nov 14, 2026
09b7377
test(github): launch CLI fixture portably on Windows
andrebrait Nov 14, 2026
56210ae
fix(github): reject stale enabled tool contexts
andrebrait Nov 14, 2026
52c98f8
fix(settings): keep locking portable across Node and Bun
andrebrait Nov 14, 2026
2c22713
fix(github): skip recovery notices after last unwatch
andrebrait Nov 14, 2026
38393de
test(outbox): assert echo ordering without pinning snapshot count
andrebrait Nov 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,13 @@ lib/
update-types.ts shared per-host software status and operation contract
updates.ts daemon-side version checks, compatibility gate and durable update state
update-worker.ts host-only install/restart helper; never starts or stops the web-server service
github/ GitHub agent tools (server only; spec `docs/specs/2026-11-13-github-watch-tool-design.md`)
gh.ts the server's `gh` runner: `GhRunner`, `checkGh` (60 s login cache), target parsing; env from `createChildProcessEnvironment`
api.ts GitHub data over `gh api`: metadata snapshots; `readItem` is the one path that returns GitHub text
events.ts pure diff of a snapshot against a watch's cursor; words metadata-only updates
watch-store.ts `<agent dir>/omp-web/github-watches.sqlite` (WAL): watches, cursors, pending updates, posted comment ids, read grants; `removeSessionWatches`/`renameSession` do nothing when the file does not exist (a session delete never creates, or on Windows holds open, a store)
watcher.ts 60 s poller: pending row, then a `prompt` outbox add, then the cursor on acceptance
tools.ts the five tools (`GITHUB_TOOL_NAMES`, `GITHUB_REPLAY_SAFE_TOOLS`), run after agent-tools' enabled and rate checks
provider-accounts.ts distinct omp accounts per provider from `omp usage` reports (Models → provider detail)
agent-client.ts agent commands from the tab: POST /api/agent/[id], or the worker's browser link in the `ws` transport; records each command's `action` sample
agent-command.ts `runAgentCommand`: the one command path behind POST /api/agent/[id] and a browser link's `cmd`
Expand Down Expand Up @@ -1341,6 +1348,17 @@ during the wait.
opened for held messages (`connectEvents(sid, "connected")`) talk to omp only
after `connected`, since any command would start a wrapper.

### GitHub agent tools (lib/github/, docs/specs/2026-11-13-github-watch-tool-design.md)
- **Server login only.** Every GitHub call uses the web server's `gh` login. The runner pins API requests to `github.com`, removes `GH_TOKEN` and `GITHUB_TOKEN` from its child environment, and uses `createChildProcessEnvironment` to exclude omp-web's secrets. omp-web never reads, stores or forwards a GitHub token. Missing or logged-out `gh` returns a tool error naming the required installation or login step.
- **Updates are metadata only.** A watch update is a `prompt`-mode outbox entry that names `OWNER/REPO#N` and the URL, and carries metadata: logins and author association, ids, URLs, review states, comment counts, thread line numbers, and CI counts by conclusion. Check names and thread file paths are left out: whoever writes the branch, a fork included, names them. `capName` (one line, no `\p{C}`, 100 characters) is only for the path in a resolve confirm, which the user reads. Pending (draft) reviews and their inline comments are left out of snapshots, so the submitted review arrives as new. Only `github_read` (`readItem`) returns GitHub-written prose, such as bodies and titles.
- **Crash idempotency.** `claimPending` atomically checks the watch's UUID registration, base cursor, pending state and posting fences before persisting deterministic bounded batches. Batch ids include the registration identity. Progress and final cursor commits are fenced by registration, pending identity and expected batch index; a restart replays the same batch id, which the outbox collapses. Final updates remove the watch only after every batch completes. Retryable delivery failures and unexpected 413s retain pending work; a 404 ends the session's watches, while other permanent refusals are logged and consumed according to `deliveryOf`.
- **Own posts.** `recordPosted` stores exact comment and associated review identities, including `review:<databaseId>` aliases for REST replies. Snapshots suppress only those identities, preserving other comments and reviews from the same account. A shared expiring posting lease spans the write and identity recording; pending claims also check the lease and recorded identities. An uncertain write is neither retried nor inferred successful from matching login/body. A crash after GitHub accepts a post but before its identity is recorded can still produce one metadata-only update.
- **Confirmations.** Reads from a local session never ask. A remote session's read, watch, unwatch and comment (on its `target`), and resolve (on the thread's item), ask once per session and repository (`hasReadGrant`/`grantRead`) unless that host's `allowAgentStart` is on. Writes (`github_comment`, `github_resolve_thread`) ask every time, from any host, unless `githubWritesUnasked` is on. omp's `host_tool_cancel` aborts the server call's `AgentToolContext.signal` (`serverToolCalls` in rpc-manager); a write checks it after its confirm, right before posting. The host rule (`startsUnasked`) lives in the leaf `lib/agent-tool-hosts.ts`; rpc-manager builds the GitHub ops with `createGithubToolOps(listHosts)`. A `PRRT_` reply and `github_resolve_thread` first look the thread up (`fetchThread`): a reply whose thread is on another item than `target` is refused, and a resolve's confirm names `OWNER/REPO#N path:line` and, from a remote session, needs that repository's read grant.
- **Replay after a restart.** `rpc-manager.ts` has two replay guards. The host-owned callback guard (`hostOwnedLifecycle` with `deliveredCallbacks`) runs again only `list_sessions` and `GITHUB_REPLAY_SAFE_TOOLS` (`watch_github`, `unwatch_github`, `github_read`). The agent-tool branch of `host_tool_call` (ahead of `answerServerAgentTool`) also runs `new_session` again while Agent tools is enabled. `github_comment` and `github_resolve_thread` never run again on either: they answer the unknown-outcome error (`unknownOutcome`), which for GitHub tools says to check with `github_read` (comments or threads) before retrying, because the post may already have happened.
- **Limits.** At most 20 watches per session (`MAX_WATCHES_PER_SESSION`). A poll cycle is 60 s, or 1.5 s per distinct watched item past 40 items (`ITEM_SPACING_MS`), with all watched items (across all sessions, one entry per item) spread across it. A snapshot costs about 1 GraphQL point, so the poller spends at most about 2400 of the login's 5000 points an hour, shared with the user's own `gh`. Each snapshot reads `rateLimit { remaining resetAt }`; below 200 points left (`BUDGET_FLOOR`) polling waits for the reset and logs once.
- **CI is reported once per head.** A head's result is sent once, when its checks visible at that poll have all completed. Workflows that start later on the same head (chained `workflow_run`) and re-runs of that head are not reported.
- **Agent tools off pauses watches.** The poller checks `agentToolsEnabled` each cycle. With **Agent tools** off, polling stops and watches are kept; turning it back on resumes from the stored cursors, so what happened meanwhile is delivered then.

### Live event stream: one socket per browser (`/api/events`)
- Browsers cap HTTP/1.1 at **6 connections per host across all tabs**, and an
SSE response pins one for its lifetime. Per-stream SSE (sidebar + host
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f
- **A distinct ompweb icon.** The rounded π inside an open orbit retains the pink–purple–cyan palette. The sidebar logo, SVG/PNG artwork, favicons, installed-app icons and tray icon now use the same mark.
- **Upgrades check the session omp before they switch.** `ompweb preflight` resolves omp exactly as the host does for a new session (`OMP_WEB_OMP_BIN` or this version's downloaded `ompweb-omp`), starts it headless with `--no-session`, and waits up to 30 s for its hello: it prints `preflight ok: <omp> <version>`, or exits 1 naming the binary and why (not found, not executable, exited with its code, or no hello with omp's last stderr). The host updater and the web app's in-app update run the new release's preflight in the live environment before they stop anything, and abort with the current install still running when it fails. Before the preflight, both run the new release's `ompweb fetch-omp`, so its runner is in place first. A release without the preflight command is checked only if it ships `ompweb-omp`; then the update fails.
- **Retry a message that was not sent.** A held **Not sent** bubble has a **Retry** button beside **Delete**. Retry stores a new copy with the same text, images, attached files and delivery mode. The server atomically retires a root it knows; for a browser-only failure, the worker removes only the exact same-session local root and siblings after a valid acceptance, even if a lost-response replay returns an empty tombstone with no `replaces`. A refusal keeps the failed message. Concurrent same-root retries share one browser copy, including across tabs. If the failed message may already have reached omp, Retry asks first, since sending it again can make it run twice.
- **GitHub tools for agents.** Five new agent tools sit behind the **Agent tools** setting. `watch_github` and `unwatch_github` keep a pull request or issue under watch, and the watching session receives updates that carry only metadata: `OWNER/REPO#N`, the URL, authors, ids, review states, line numbers and CI result counts. An update never carries a title, body, branch name, check name, file path or comment text. `github_read` reads an item's summary, comments, reviews, threads, checks, files or diff. `github_comment` posts a comment and `github_resolve_thread` resolves a review thread. They need the GitHub CLI (`gh`) installed and logged in on the omp-web server (`gh auth login` there), and act as that account. A session on another host is asked once per session and repository before its first watch, unwatch, read, comment or thread resolve on it. A host with **Let agents start sessions here** on is not asked. Each write asks before it posts, unless **Settings → System → Allow agent GitHub writes without asking** is on (off by default). Watches pause while Agent tools is off and resume from where they stopped when it is turned back on.
- **Rename the cluster.** Settings → Hosts shows the cluster's name with a **Rename cluster** button; the sidebar group follows. The cluster is still created as "Cluster" when the first host joins, so rename it afterwards. `PATCH /api/hosts { poolId, label }` renames a pool without replacing the pool registry.
- **Cluster members use this machine's omp configuration.**
**Before upgrading:** every cluster member needs an ompweb host daemon at link protocol 10. Until a member's daemon is updated, sessions cannot start there (the start is refused and names the update), cluster placement skips it, a host below protocol 10 cannot join, and Settings → Hosts says it needs an update. Once its daemon is updated, every host already in the first pool receives this machine's configuration, secrets included (API keys, MCP environments and headers, tokens), automatically and without a confirmation having been shown, and its own omp configuration is replaced until it leaves the cluster; it is kept in `<agent dir>/omp-web/own-config/` on the member and put back on leaving. To avoid that for a host, remove it from the first pool (`PUT /api/hosts`, or editing `~/.omp/agent/omp-web/pools.json`) before updating its daemon. Update the web server first, then the members.
Expand Down
7 changes: 7 additions & 0 deletions app/api/sessions/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { invalidateSessionCaches } from "@/lib/session-reader";
import { parseJsonWithinLimit, RequestBodyTooLargeError } from "@/lib/bounded-form-data";
import { clearExitedRpcSession, getLiveRpcSessionIds, getRpcSession, WebRpcError } from "@/lib/rpc-manager";
import { forgetSessionReplacementMappings } from "@/lib/message-outbox";
import { removeSessionWatches } from "@/lib/github/watch-store";

/** Stable, client-safe error body for catch-all handlers: details go to the
* server log only, never to the browser. */
Expand Down Expand Up @@ -143,6 +144,12 @@ export async function DELETE(
}
invalidateHostSessionList(resolved.host.id);
clearExitedRpcSession(id);
try {
removeSessionWatches(id);
} catch (error) {
// The delete succeeded; a watch left behind ends at the outbox's 404 for this session.
console.warn(`[omp-web] GitHub watches of ${id} were not removed: ${error instanceof Error ? error.message : String(error)}`);
}
return Response.json({ ok: true, ...result.value });
} catch (error) {
return sessionsErrorResponse(error);
Expand Down
17 changes: 14 additions & 3 deletions app/api/web-settings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,22 @@ import { BROWSER_TRANSPORTS } from "@/lib/event-stream-protocol";
import { syncInterruptibleSessions } from "@/lib/rpc-manager";
import { parseAgentEnvText, sanitizeAgentEnvValues, type AgentEnvParseResult } from "@/lib/omp/agent-env";
import { pushUploadRetention } from "@/lib/hosts/upload-retention-sync";
import { browserTransportState, loadWebServerSettings, saveWebServerSettings, type WebServerSettings } from "@/lib/web-settings";
import { browserTransportState, loadWebServerSettings, readWebServerSettingsFromDisk, saveWebServerSettings, type WebServerSettings } from "@/lib/web-settings";
import { isRecord } from "@/lib/type-guards";
import { isUploadRetentionDays } from "@/lib/upload-limits";

/** GET/PUT /api/web-settings - omp-web's own server-side settings. */
export async function GET() {
return Response.json(readSettings());
try {
return Response.json(readSettings());
} catch {
return Response.json({ error: "Web settings could not be read", code: "settings_unavailable" }, { status: 503 });
}
}

/** Never surface a variable lib/omp/agent-env.ts would refuse to spawn with. */
function readSettings(): WebServerSettings {
const settings = loadWebServerSettings();
const settings = readWebServerSettingsFromDisk();
return { ...settings, agentEnv: sanitizeAgentEnvValues(settings.agentEnv).values };
}

Expand Down Expand Up @@ -49,6 +53,13 @@ export async function PUT(req: Request) {
patch.agentToolsEnabled = payload.agentToolsEnabled;
}

if (payload.githubWritesUnasked !== undefined) {
if (typeof payload.githubWritesUnasked !== "boolean") {
return Response.json({ error: "githubWritesUnasked must be a boolean", code: "invalid_settings" }, { status: 400 });
}
patch.githubWritesUnasked = payload.githubWritesUnasked;
}

if (payload.browserTransport !== undefined) {
const transport = BROWSER_TRANSPORTS.find((value) => value === payload.browserTransport);
if (!transport) {
Expand Down
117 changes: 117 additions & 0 deletions components/SettingsConfig.github-writes.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import "../tests/setup-dom.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import { act, createElement } from "react";
const { cleanup, fireEvent, render, within } = await import("@testing-library/react/pure.js");
const { SettingsConfig } = await import("./SettingsConfig");

const noop = () => {};
const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 20)));
const LABEL = "Allow agent GitHub writes without asking";

const settingsProps = {
activeTab: "system",
toolCallsDefaultCollapsed: false, onToolCallsDefaultCollapsedChange: noop,
hideToolActivity: false, onHideToolActivityChange: noop,
expandThinkingByDefault: false, onExpandThinkingByDefaultChange: noop, onHideThinkingBlockChange: noop,
providerUsageVisible: false, onProviderUsageVisibleChange: noop,
scopeNativeSelectAll: false, onScopeNativeSelectAllChange: noop,
openUrlAutomatically: false, onOpenUrlAutomaticallyChange: noop,
cwd: null, remoteHost: false, sessionId: null,
onModelsSaved: noop, onPluginsReloaded: noop,
appUpdate: null, onRefreshAppUpdate: async () => null,
onRequestAppUpdate: noop, onSelectTab: noop, onClose: noop,
};

/** `get` answers the settings load; each PUT waits until the test releases it. */
function fakeSettings(t, get) {
const puts = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url, init) => {
if (String(url) !== "/api/web-settings") return Response.json({});
if (init?.method === "PUT") {
const { promise, resolve, reject } = Promise.withResolvers();
puts.push({ body: init.body, release: () => resolve(Response.json({})), fail: () => reject(new TypeError("Failed to fetch")) });
return promise;
}
return get();
};
t.after(() => {
cleanup();
globalThis.fetch = originalFetch;
});
return puts;
}

test("a failed settings load leaves the GitHub writes switch unknown and disabled", async (t) => {
fakeSettings(t, () => Response.json({}, { status: 500 }));
const { container } = render(createElement(SettingsConfig, settingsProps));
await settle();
assert.equal(within(container).getByRole("switch", { name: LABEL }).disabled, true);
});

test("a lost GitHub writes save reloads the persisted permission before re-enabling", async (t) => {
let persisted = false;
let loads = 0;
const puts = fakeSettings(t, () => {
loads++;
return Response.json({ githubWritesUnasked: persisted });
});
const { container } = render(createElement(SettingsConfig, settingsProps));
await settle();
const toggle = within(container).getByRole("switch", { name: LABEL });

fireEvent.click(toggle);
await settle();
assert.deepEqual(puts.map((put) => put.body), ['{"githubWritesUnasked":true}']);
const previousLoads = loads;
persisted = true;
puts[0].fail();
await settle();
assert.ok(loads > previousLoads, "the saved permission is reloaded after the uncertain PUT");
assert.equal(toggle.disabled, false);
assert.equal(toggle.getAttribute("aria-checked"), "true");
});

test("a failed GitHub writes reconciliation GET leaves the permission unknown and disabled", async (t) => {
let failReload = false;
let loads = 0;
const puts = fakeSettings(t, () => {
loads++;
return failReload ? Response.json({}, { status: 500 }) : Response.json({ githubWritesUnasked: false });
});
const { container } = render(createElement(SettingsConfig, settingsProps));
await settle();
const toggle = within(container).getByRole("switch", { name: LABEL });

fireEvent.click(toggle);
await settle();
const previousLoads = loads;
failReload = true;
puts[0].fail();
await settle();
assert.ok(loads > previousLoads, "the switch attempts to reload saved settings after the uncertain PUT");
assert.equal(toggle.disabled, true);
});


test("the GitHub writes switch takes no second change until its save lands", async (t) => {
const puts = fakeSettings(t, () => Response.json({ githubWritesUnasked: false }));
const { container } = render(createElement(SettingsConfig, settingsProps));
await settle();
const toggle = within(container).getByRole("switch", { name: LABEL });
assert.equal(toggle.disabled, false);

fireEvent.click(toggle);
await settle();
assert.deepEqual(puts.map((p) => p.body), ['{"githubWritesUnasked":true}']);
assert.equal(toggle.disabled, true, "an off click now could reach the server before the on");
fireEvent.click(toggle);
await settle();
assert.equal(puts.length, 1);

puts[0].release();
await settle();
assert.equal(toggle.disabled, false);
assert.equal(toggle.getAttribute("aria-checked"), "true");
});
Loading
Loading