From 7d5fcc2f99c5bb3ba575a02f7ccb80d611561dcb Mon Sep 17 00:00:00 2001 From: dragon Date: Sat, 24 Oct 2026 10:10:34 +0800 Subject: [PATCH 1/2] Show today's voice use in the Account tab (#170) The daemon keeps today's use from the service (read at sign-in and when the App opens the tab, POST /v1/account/refresh), counts each command the voice service recognizes, and remembers an allowance or global-cap refusal until the UTC day ends; /v1/status carries both. Both Apps show "Voice today: 7 of 100", or why voice is refused until midnight UTC. Co-Authored-By: Claude Opus 5.5 --- .../zh-Hans.lproj/Localizable.strings | 3 + app/Sources/SelfTest/main.swift | 4 + app/Sources/VibeBuddy/DaemonClient.swift | 3 + app/Sources/VibeBuddy/SettingsView.swift | 16 +++ app/Sources/VibeBuddyCore/Status.swift | 19 ++- daemon/src/account.rs | 114 +++++++++++++++++- daemon/src/main.rs | 18 +++ daemon/src/speech.rs | 20 ++- desktop/src/client.rs | 5 + desktop/src/main.rs | 12 +- desktop/src/status.rs | 15 +++ docs/app.md | 4 +- 12 files changed, 222 insertions(+), 11 deletions(-) diff --git a/app/Localization/zh-Hans.lproj/Localizable.strings b/app/Localization/zh-Hans.lproj/Localizable.strings index 0b122fb5..c8c22aeb 100644 --- a/app/Localization/zh-Hans.lproj/Localizable.strings +++ b/app/Localization/zh-Hans.lproj/Localizable.strings @@ -364,3 +364,6 @@ "Couldn't sign in: %@" = "登录失败:%@"; "Couldn't sign out: %@" = "退出登录失败:%@"; "Couldn't delete the account: %@" = "删除账户失败:%@"; +"Voice today: %lld of %lld" = "今天的语音:已用 %lld / %lld 条"; +"Today's voice is used up. It comes back at midnight UTC." = "今天的语音已用完,UTC 零点后恢复。"; +"Voice is paused for everyone today. It comes back at midnight UTC." = "今天的语音已全局暂停,UTC 零点后恢复。"; diff --git a/app/Sources/SelfTest/main.swift b/app/Sources/SelfTest/main.swift index f3e9655a..1abaa567 100644 --- a/app/Sources/SelfTest/main.swift +++ b/app/Sources/SelfTest/main.swift @@ -92,6 +92,10 @@ do { {"available":true,"signed_in":true,"email":null,"github_id":583231,"github_login":"octocat"} """.utf8)) check(account == AccountStatus(available: true, signedIn: true, githubID: 583231, githubLogin: "octocat"), "account decodes") + let used = try StatusCoding.decoder().decode(AccountStatus.self, from: Data(""" + {"available":true,"signed_in":true,"github_id":1,"today":{"day":"2026-10-24","commands":7,"commands_limit":100},"refused":"allowance"} + """.utf8)) + check(used.today == AccountUsage(day: "2026-10-24", commands: 7, commandsLimit: 100) && used.refused == "allowance", "today's voice use decodes") } catch { check(false, "status decoding threw: \(error)") } diff --git a/app/Sources/VibeBuddy/DaemonClient.swift b/app/Sources/VibeBuddy/DaemonClient.swift index b153b782..0a54f6ee 100644 --- a/app/Sources/VibeBuddy/DaemonClient.swift +++ b/app/Sources/VibeBuddy/DaemonClient.swift @@ -144,6 +144,9 @@ struct DaemonClient { func signOut() async throws { try await accountCall("/v1/account/sign-out", method: "POST") } + /// Has the daemon read today's voice use from the service; it comes back in the status. + func refreshAccount() async throws { try await accountCall("/v1/account/refresh", method: "POST") } + /// Deletes the account on the service; when the service doesn't, the daemon stays signed in. func deleteAccount() async throws { try await accountCall("/v1/account", method: "DELETE") } diff --git a/app/Sources/VibeBuddy/SettingsView.swift b/app/Sources/VibeBuddy/SettingsView.swift index 8ada6fbb..aa49ac27 100644 --- a/app/Sources/VibeBuddy/SettingsView.swift +++ b/app/Sources/VibeBuddy/SettingsView.swift @@ -820,6 +820,11 @@ struct AccountView: View { } else if let account, account.available { if account.signedIn { Text(signedInText) + if let refusal { + Text(refusal).foregroundStyle(.orange) + } else if let today = account.today { + Text("Voice today: \(today.commands) of \(today.commandsLimit)").foregroundStyle(.secondary) + } HStack { Button("Sign out") { signOut() } Button("Delete account…") { confirmDelete() } @@ -839,6 +844,17 @@ struct AccountView: View { } .formStyle(.grouped) .onChange(of: account?.signedIn) { waiting = false } + // Reading the day's use costs a request to the service, so only when the tab is shown. + .task { if account?.signedIn == true { try? await model.client.refreshAccount() } } + } + + /// Why voice is refused today, if it is. + private var refusal: String? { + switch account?.refused { + case nil: nil + case "global_cap": String(localized: "Voice is paused for everyone today. It comes back at midnight UTC.") + default: String(localized: "Today's voice is used up. It comes back at midnight UTC.") + } } private var signedInText: String { diff --git a/app/Sources/VibeBuddyCore/Status.swift b/app/Sources/VibeBuddyCore/Status.swift index 6e2d1870..8b91ce50 100644 --- a/app/Sources/VibeBuddyCore/Status.swift +++ b/app/Sources/VibeBuddyCore/Status.swift @@ -34,9 +34,24 @@ public struct AccountStatus: Codable, Equatable { public var githubID: Int? /// Looked up on GitHub when signing in; nil if that lookup failed. public var githubLogin: String? - enum CodingKeys: String, CodingKey { case available, signedIn = "signed_in", email, githubID = "github_id", githubLogin = "github_login" } - public init(available: Bool, signedIn: Bool, email: String? = nil, githubID: Int? = nil, githubLogin: String? = nil) { + /// Voice used in the current UTC day, when the daemon knows; absent from daemons before it. + public var today: AccountUsage? + /// Why the service refused voice today: `allowance` or `global_cap`. + public var refused: String? + enum CodingKeys: String, CodingKey { case available, signedIn = "signed_in", email, githubID = "github_id", githubLogin = "github_login", today, refused } + public init(available: Bool, signedIn: Bool, email: String? = nil, githubID: Int? = nil, githubLogin: String? = nil, today: AccountUsage? = nil, refused: String? = nil) { self.available = available; self.signedIn = signedIn; self.email = email; self.githubID = githubID; self.githubLogin = githubLogin + self.today = today; self.refused = refused + } +} + +public struct AccountUsage: Codable, Equatable { + public var day: String + public var commands: Int + public var commandsLimit: Int + enum CodingKeys: String, CodingKey { case day, commands, commandsLimit = "commands_limit" } + public init(day: String, commands: Int, commandsLimit: Int) { + self.day = day; self.commands = commands; self.commandsLimit = commandsLimit } } diff --git a/daemon/src/account.rs b/daemon/src/account.rs index 43a6526d..f1703369 100644 --- a/daemon/src/account.rs +++ b/daemon/src/account.rs @@ -29,6 +29,26 @@ pub struct AccountStatus { pub github_id: Option, /// Looked up on GitHub when signing in, for the App to show; the service keeps only the id. pub github_login: Option, + /// Voice used in the current UTC day, when known: read from the service at sign-in and when the App asks, and + /// counted here as commands are recognized. + pub today: Option, + /// Why the service last refused voice today (`allowance` or `global_cap`), until the UTC day ends. + pub refused: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct Usage { + /// The UTC day, `2026-10-24`. + pub day: String, + pub commands: u64, + pub commands_limit: u64, +} + +/// What the daemon knows of today's voice; cleared at the UTC day's end by [`Account::status`]. +#[derive(Default)] +struct Today { + usage: Option, + refused: Option<(String, String)>, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -78,6 +98,9 @@ pub struct Account { github_api: String, saved: Mutex>, pending: Mutex>, + today: Mutex, + /// Bumped whenever [`AccountStatus::today`] or [`AccountStatus::refused`] changes, for the status stream. + usage_changed: watch::Sender, /// Whether signed in, for whoever must follow it: the status stream and the box's offer of voice. signed_in: watch::Sender, } @@ -88,6 +111,7 @@ impl Account { let file = config_dir.map(|dir| dir.join(ACCOUNT_FILE)); let saved = file.as_deref().and_then(load); let (signed_in, _) = watch::channel(saved.is_some()); + let (usage_changed, _) = watch::channel(0); Self { base, file, @@ -95,10 +119,17 @@ impl Account { github_api: "https://api.github.com".to_owned(), saved: Mutex::new(saved), pending: Mutex::new(None), + today: Mutex::default(), + usage_changed, signed_in, } } + /// Changes whenever today's usage or a refusal changes. + pub fn watch_usage(&self) -> watch::Receiver { + self.usage_changed.subscribe() + } + /// Changes each time the account is signed in or out, from here or by the service refusing the session. pub fn watch(&self) -> watch::Receiver { self.signed_in.subscribe() @@ -112,15 +143,68 @@ impl Account { pub fn status(&self) -> AccountStatus { let saved = self.saved.lock().unwrap().clone(); + let day = utc_day(); + let today = self.today.lock().unwrap(); + let signed_in = saved.is_some(); AccountStatus { available: self.base.is_some() && self.file.is_some(), - signed_in: saved.is_some(), + signed_in, email: saved.as_ref().and_then(|s| s.email.clone()), github_id: saved.as_ref().and_then(|s| s.github_id), github_login: saved.and_then(|s| s.github_login), + // A new UTC day starts over: nothing used, nothing refused. + today: today.usage.clone().filter(|_| signed_in).map(|usage| if usage.day == day { usage } else { Usage { day: day.clone(), commands: 0, ..usage } }), + refused: today.refused.as_ref().filter(|(on, _)| signed_in && *on == day).map(|(_, reason)| reason.clone()), } } + /// A command was recognized by the voice service, which counted it. + pub fn note_command(&self) { + let day = utc_day(); + let mut today = self.today.lock().unwrap(); + if let Some(usage) = &mut today.usage { + if usage.day != day { + *usage = Usage { day, commands: 0, ..usage.clone() }; + } + usage.commands += 1; + } + drop(today); + self.usage_changed.send_modify(|n| *n += 1); + } + + /// The service refused voice for `reason` (`allowance` or `global_cap`). + pub fn note_refusal(&self, reason: &str) { + self.today.lock().unwrap().refused = Some((utc_day(), reason.to_owned())); + self.usage_changed.send_modify(|n| *n += 1); + } + + /// Reads today's usage from the service. Blocking. + pub fn refresh_usage(&self) -> Result<(), AccountError> { + let token = self.token().ok_or(AccountError::NotSignedIn)?; + let base = self.base.clone().ok_or(AccountError::Unavailable)?; + let (status, text) = read(crate::speech::agent(TIMEOUT).get(&format!("{base}/v1/account")).header("Authorization", &format!("Bearer {token}")).call())?; + if status == 401 { + self.forget(); + return Err(AccountError::NotSignedIn); + } + if status != 200 { + return Err(AccountError::Service(format!("the account couldn't be read: HTTP {status}"))); + } + let account: serde_json::Value = serde_json::from_str(&text).map_err(|error| AccountError::Service(error.to_string()))?; + self.take_usage(&account); + Ok(()) + } + + /// Today's usage from a `GET /v1/account` answer (docs/api.md). + fn take_usage(&self, account: &serde_json::Value) { + let usage = match (account["today"]["day"].as_str(), account["today"]["commands"].as_u64(), account["limits"]["commands"].as_u64()) { + (Some(day), Some(commands), Some(limit)) => Some(Usage { day: day.to_owned(), commands, commands_limit: limit }), + _ => None, + }; + self.today.lock().unwrap().usage = usage; + self.usage_changed.send_modify(|n| *n += 1); + } + /// The session for calls to the service, when signed in. pub fn token(&self) -> Option { self.saved.lock().unwrap().as_ref().map(|saved| saved.token.clone()) @@ -176,6 +260,7 @@ impl Account { return Err(AccountError::Service(format!("the new session didn't work: HTTP {status}"))); } let account: serde_json::Value = serde_json::from_str(&text).map_err(|error| AccountError::Service(error.to_string()))?; + self.take_usage(&account); let github_id = account["github_id"].as_u64(); let saved = Saved { token, @@ -258,6 +343,10 @@ pub fn signed_in_for_tests(dir: &Path, token: &str) -> Account { Account::new(Some("http://127.0.0.1:9".to_owned()), Some(dir), 7331) } +fn utc_day() -> String { + chrono::Utc::now().format("%Y-%m-%d").to_string() +} + fn load(path: &Path) -> Option { let text = std::fs::read_to_string(path).ok()?; match serde_json::from_str(&text) { @@ -457,6 +546,29 @@ mod tests { assert!(seen.lock().unwrap()[1].starts_with("DELETE /v1/account")); } + #[test] + fn keeps_todays_usage_counts_commands_and_remembers_a_refusal() { + let dir = scratch(); + let day = utc_day(); + let account_answer = format!(r#"{{"id":"a","github_id":1,"today":{{"day":"{day}","commands":7,"replies":9}},"limits":{{"commands":100,"replies":300}}}}"#); + let (service, _) = serve(vec![(200, Box::leak(account_answer.into_boxed_str()))]); + let account = signed_in(&service, dir.as_path()); + let mut changes = account.watch_usage(); + assert_eq!(account.status().today, None); + account.refresh_usage().unwrap(); + assert_eq!(account.status().today, Some(Usage { day: day.clone(), commands: 7, commands_limit: 100 })); + account.note_command(); + assert_eq!(account.status().today.unwrap().commands, 8); + account.note_refusal("allowance"); + assert_eq!(account.status().refused.as_deref(), Some("allowance")); + assert!(changes.has_changed().unwrap()); + // A refusal from another day is forgotten; signed out, nothing is shown. + account.today.lock().unwrap().refused = Some(("2001-01-01".to_owned(), "global_cap".to_owned())); + assert_eq!(account.status().refused, None); + account.forget(); + assert_eq!((account.status().today, account.status().refused), (None, None)); + } + #[test] fn is_unavailable_without_a_service() { let dir = scratch(); diff --git a/daemon/src/main.rs b/daemon/src/main.rs index a92a3fb2..df18049b 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -496,6 +496,14 @@ fn follow_account(state: &AppState) { offer_voice_again(&follower).await; } }); + // Today's usage and refusals are only shown. + let mut usage = state.account.watch_usage(); + let follower = state.clone(); + tokio::spawn(async move { + while usage.changed().await.is_ok() { + follower.notify_status(); + } + }); } /// Checks the resident session's readiness every [`resident::READINESS_TTL`] and tells the box each time it flips. @@ -663,6 +671,7 @@ fn app(state: AppState) -> Router { .route("/v1/account/sign-in", post(post_sign_in)) .route("/v1/account/callback", get(get_account_callback)) .route("/v1/account/sign-out", post(post_sign_out)) + .route("/v1/account/refresh", post(post_account_refresh)) .with_state(state) } @@ -1712,6 +1721,15 @@ async fn post_sign_out(State(state): State) -> (StatusCode, Json) -> (StatusCode, Json) { + let account = state.account.clone(); + match tokio::task::spawn_blocking(move || account.refresh_usage()).await.unwrap_or_else(|error| Err(account::AccountError::Service(error.to_string()))) { + Ok(()) => (StatusCode::OK, Json(ApiResponse { accepted: true, message: "refreshed".to_owned() })), + Err(error) => account_refusal(&error), + } +} + async fn delete_account(State(state): State) -> (StatusCode, Json) { let account = state.account.clone(); let result = tokio::task::spawn_blocking(move || account.delete()).await.unwrap_or_else(|error| Err(account::AccountError::Service(error.to_string()))); diff --git a/daemon/src/speech.rs b/daemon/src/speech.rs index b379e6a1..9f71248a 100644 --- a/daemon/src/speech.rs +++ b/daemon/src/speech.rs @@ -179,7 +179,11 @@ impl Recognizer for VoiceService { if let Some(refusal) = refusal(status, &body, self.session.as_deref()) { return Err(refusal); } - Ok(Self::parse(status, &body)?) + let transcript = Self::parse(status, &body)?; + if let Some(session) = &self.session { + session.note_command(); + } + Ok(transcript) } fn ready(&self) -> bool { @@ -197,10 +201,16 @@ pub fn refusal(status: u16, body: &str, session: Option<&Account>) -> Option Some(match serde_json::from_str::(body).ok().and_then(|value| value.get("reason")?.as_str().map(str::to_owned)).as_deref() { - Some("global_cap") => RecognitionError { reason: "global_cap", message: "Voice is paused for today".to_owned() }, - _ => RecognitionError { reason: "allowance", message: "Today's voice is used up".to_owned() }, - }), + 429 => { + let refusal = match serde_json::from_str::(body).ok().and_then(|value| value.get("reason")?.as_str().map(str::to_owned)).as_deref() { + Some("global_cap") => RecognitionError { reason: "global_cap", message: "Voice is paused for today".to_owned() }, + _ => RecognitionError { reason: "allowance", message: "Today's voice is used up".to_owned() }, + }; + if let Some(session) = session { + session.note_refusal(refusal.reason); + } + Some(refusal) + } _ => None, } } diff --git a/desktop/src/client.rs b/desktop/src/client.rs index bbdd8ddb..e19b410e 100644 --- a/desktop/src/client.rs +++ b/desktop/src/client.rs @@ -324,6 +324,11 @@ pub async fn sign_in() -> Result { } /// Ends this computer's session only. +/// Has the daemon read today's voice use from the service; it comes back in the status. +pub async fn refresh_account() -> Result<(), String> { + post("/v1/account/refresh", None).await +} + pub async fn sign_out() -> Result<(), String> { post("/v1/account/sign-out", None).await } diff --git a/desktop/src/main.rs b/desktop/src/main.rs index a876f1a6..ef26e19f 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -520,6 +520,10 @@ impl App { self.conversation_seen = None; return Task::done(Message::PollConversation); } + // Reading the day's use costs a request to the service, so only when the tab opens; the status brings it. + if tab == Tab::Account && self.status.as_ref().is_some_and(|status| status.account.signed_in) { + return Task::perform(client::refresh_account(), |_| Message::AccountDone(Ok(()))); + } } Message::Resident(Ok(enabled)) => { self.resident = enabled; @@ -1942,7 +1946,13 @@ impl App { .spacing(8) .into() }; - column![text(who), actions].spacing(12).into() + let today = match (account.refused.as_deref(), &account.today) { + (Some("global_cap"), _) => Some(text(tr("Voice is paused for everyone today. It comes back at midnight UTC.", &[])).size(13).style(text::warning)), + (Some(_), _) => Some(text(tr("Today's voice is used up. It comes back at midnight UTC.", &[])).size(13).style(text::warning)), + (None, Some(usage)) => Some(text(tr("Voice today: %lld of %lld", &[&usage.commands, &usage.commands_limit])).size(13).style(text::secondary)), + (None, None) => None, + }; + column![text(who)].push(today).push(actions).spacing(12).into() } fn advanced(&self) -> Element<'_, Message> { diff --git a/desktop/src/status.rs b/desktop/src/status.rs index f9e8d606..586d0eed 100644 --- a/desktop/src/status.rs +++ b/desktop/src/status.rs @@ -32,6 +32,18 @@ pub struct Account { pub github_id: Option, /// Looked up on GitHub when signing in; the service keeps only the id. pub github_login: Option, + /// Voice used in the current UTC day, when the daemon knows. + pub today: Option, + /// Why the service refused voice today: `allowance` or `global_cap`. + pub refused: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[serde(default)] +pub struct Usage { + pub day: String, + pub commands: u64, + pub commands_limit: u64, } /// The daemon's view of updates; which firmware to offer is its call, as on the Mac. @@ -452,6 +464,9 @@ mod tests { .expect("account"); assert!(account.available && account.signed_in); assert_eq!((account.github_id, account.github_login.as_deref(), account.email), (Some(60097), Some("octocat"), None)); + let used: Account = serde_json::from_str(r#"{"signed_in":true,"today":{"day":"2026-10-24","commands":7,"commands_limit":100},"refused":"global_cap"}"#).expect("account"); + assert_eq!(used.today, Some(Usage { day: "2026-10-24".to_owned(), commands: 7, commands_limit: 100 })); + assert_eq!(used.refused.as_deref(), Some("global_cap")); } #[test] diff --git a/docs/app.md b/docs/app.md index 70f5c5b3..41011b14 100644 --- a/docs/app.md +++ b/docs/app.md @@ -31,7 +31,7 @@ It keeps using local HTTP on `127.0.0.1:7331`. New additions: - `GET /v1/status`: link, current mode, firmware and daemon build identifiers, today's stats, and the time of the most recent hook event. - `GET /v1/status/stream`: SSE, pushed whenever the status changes. - `GET /v1/resident` and `GET /v1/resident/history`: whether the resident session is on and ready (Codex installed and signed in, or Claude Code installed with the user's Anthropic API key), which Agent answers, a change marker for its history, and its last exchanges, for the Conversation tab; `PUT /v1/resident/brain` and `PUT`/`DELETE /v1/resident/anthropic-key` for its settings, which are kept out of `/v1/config` (the key is never sent back). -- `POST /v1/account/sign-in`, `POST /v1/account/sign-out` and `DELETE /v1/account` for the VibeBuddy account (ADR-0018). Signing in answers a URL for the App to open in the browser, which comes back to the daemon's own `/v1/account/callback`; the session is the daemon's (`config_dir()/account`) and never reaches the App, which learns of the result from `account` in the status stream. +- `POST /v1/account/sign-in`, `POST /v1/account/sign-out`, `POST /v1/account/refresh` (today's use) and `DELETE /v1/account` for the VibeBuddy account (ADR-0018). Signing in answers a URL for the App to open in the browser, which comes back to the daemon's own `/v1/account/callback`; the session is the daemon's (`config_dir()/account`) and never reaches the App, which learns of the result from `account` in the status stream. - Write operations: write a voice pack, update firmware, take a screenshot, restart the daemon itself. Config is owned by the daemon and stored in `~/Library/Application Support/VibeBuddy/config.json` (current voice, notification toggle and so on). The app reads and writes it through the API and never touches the file directly; the existing environment variables remain as a development-time override. The app itself uses UserDefaults only for interface state such as window position. @@ -70,7 +70,7 @@ Six tabs (seven while the resident session is on), styled like System Settings: ![The Conversation tab, with nothing said yet](images/app-conversation-empty-zh.png) ![The Conversation tab when the resident session can't be read](images/app-conversation-error-zh.png) -**Account**: signing in to the VibeBuddy account, which voice commands need (ADR-0018). Signed out, it says signing in opens the browser, with "Sign in"; the browser page offers GitHub, and email once mail is set up, then says to go back to VibeBuddy, while the tab shows "Waiting for the browser…" until the daemon reports the session. Signed in, it names the account ("Signed in with GitHub as …", the login looked up on GitHub by the daemon; the service keeps only the numeric id) with "Sign out" and "Delete account…", which asks first because it deletes the account for every computer. A build without a service to sign in to, such as a release before production opens, says signing in isn't available. The Linux app has the same tab. +**Account**: signing in to the VibeBuddy account, which voice commands need (ADR-0018). Signed out, it says signing in opens the browser, with "Sign in"; the browser page offers GitHub, and email once mail is set up, then says to go back to VibeBuddy, while the tab shows "Waiting for the browser…" until the daemon reports the session. Signed in, it names the account ("Signed in with GitHub as …", the login looked up on GitHub by the daemon; the service keeps only the numeric id) and today's voice use ("Voice today: 7 of 100", which the daemon reads from the service when the tab opens and counts up as commands are recognized), or, once the service has refused voice for the day, that the allowance is used up or that voice is paused for everyone, until midnight UTC. Below are "Sign out" and "Delete account…", which asks first because it deletes the account for every computer. A build without a service to sign in to, such as a release before production opens, says signing in isn't available. The Linux app has the same tab. **Advanced**: Open logs, restart the daemon, export diagnostics (logs, config and both sides' build identifiers bundled into one package). From 741dd4edd8c44634b6abda5510b87192ccc26399 Mon Sep 17 00:00:00 2001 From: dragon Date: Sat, 24 Oct 2026 10:10:50 +0800 Subject: [PATCH 2/2] daemon: a test binding needn't be mutable Co-Authored-By: Claude Opus 5.5 --- daemon/src/account.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daemon/src/account.rs b/daemon/src/account.rs index f1703369..5736f45b 100644 --- a/daemon/src/account.rs +++ b/daemon/src/account.rs @@ -553,7 +553,7 @@ mod tests { let account_answer = format!(r#"{{"id":"a","github_id":1,"today":{{"day":"{day}","commands":7,"replies":9}},"limits":{{"commands":100,"replies":300}}}}"#); let (service, _) = serve(vec![(200, Box::leak(account_answer.into_boxed_str()))]); let account = signed_in(&service, dir.as_path()); - let mut changes = account.watch_usage(); + let changes = account.watch_usage(); assert_eq!(account.status().today, None); account.refresh_usage().unwrap(); assert_eq!(account.status().today, Some(Usage { day: day.clone(), commands: 7, commands_limit: 100 }));