@agent-native/core 0.175.4 → 0.176.0-nightly-20260826203614

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/.agents/skills/recording/SKILL.md +9 -8
  3. package/corpus/templates/clips/actions/import-loom-recording.ts +1 -1
  4. package/corpus/templates/clips/actions/lib/loom-import-job.ts +52 -35
  5. package/corpus/templates/clips/app/components/clips-avatar.tsx +46 -0
  6. package/corpus/templates/clips/app/components/library/recording-card.tsx +8 -7
  7. package/corpus/templates/clips/app/components/library/space-card.tsx +8 -5
  8. package/corpus/templates/clips/app/components/meetings/attendee-stack.tsx +7 -8
  9. package/corpus/templates/clips/app/components/meetings/transcript-bubbles.tsx +19 -12
  10. package/corpus/templates/clips/app/components/workspace/members-list.tsx +8 -6
  11. package/corpus/templates/clips/app/components/workspace/notifications-list.tsx +8 -6
  12. package/corpus/templates/clips/app/components/workspace/top-creators-table.tsx +8 -6
  13. package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +8 -7
  14. package/corpus/templates/clips/changelog/2026-08-26-detected-calls-no-longer-duplicate-meetings-or-strand-the-prompt.md +6 -0
  15. package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +39 -22
  16. package/corpus/templates/clips/desktop/src-tauri/src/adhoc_meetings_watcher.rs +1765 -219
  17. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +4 -0
  18. package/corpus/templates/clips/desktop/src-tauri/src/meetings_watcher.rs +45 -30
  19. package/corpus/templates/clips/desktop/src-tauri/src/notifications.rs +91 -0
  20. package/corpus/templates/clips/server/routes/_agent-native/google/callback.get.ts +16 -0
  21. package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +44 -22
  22. package/corpus/templates/content/app/components/ui/avatar.tsx +1 -0
  23. package/corpus/templates/design/app/components/visual-editor/ReviewCanvasPins.tsx +4 -1
  24. package/corpus/templates/mail/actions/get-email.ts +68 -21
  25. package/corpus/templates/mail/actions/get-thread.ts +92 -51
  26. package/corpus/templates/mail/app/hooks/use-emails.ts +7 -4
  27. package/corpus/templates/mail/server/lib/mail-connector-catalog.ts +7 -4
  28. package/corpus/templates/plan/app/pages/PlansPage.tsx +23 -15
  29. package/corpus/templates/slides/app/components/comments/SlideCommentsPanel.tsx +18 -7
  30. package/corpus/templates/slides/app/components/ui/avatar.tsx +1 -0
  31. package/corpus/templates/tasks/DEVELOPING.md +8 -0
  32. package/corpus/templates/tasks/drizzle.config.ts +3 -1
  33. package/corpus/templates/tasks/drizzle.local.config.ts +6 -0
  34. package/corpus/templates/tasks/package.json +2 -1
  35. package/corpus/templates/tasks/server/db/migrations/meta/0000_snapshot.json +234 -0
  36. package/corpus/templates/tasks/server/db/migrations/meta/_journal.json +13 -0
  37. package/corpus/templates/tasks/server/db/migrations.ts +110 -0
  38. package/corpus/templates/tasks/server/plugins/db.ts +3 -87
  39. package/dist/client/review/ReviewThreadPanel.js +4 -2
  40. package/dist/collab/client.js +20 -5
  41. package/dist/collab/presence.d.ts +3 -1
  42. package/dist/collab/presence.js +48 -45
  43. package/dist/collab/routes.d.ts +1 -1
  44. package/dist/collab/struct-routes.d.ts +1 -1
  45. package/dist/db/drizzle-config.d.ts +7 -0
  46. package/dist/db/drizzle-config.js +35 -9
  47. package/dist/db/drizzle-migrations.d.ts +28 -0
  48. package/dist/db/drizzle-migrations.js +81 -0
  49. package/dist/db/index.d.ts +1 -1
  50. package/dist/db/migrations.d.ts +13 -1
  51. package/dist/db/migrations.js +60 -31
  52. package/dist/db/schema.js +4 -1
  53. package/dist/deploy/build.d.ts +6 -0
  54. package/dist/deploy/build.js +41 -0
  55. package/dist/observability/routes.d.ts +5 -5
  56. package/dist/provider-api/actions/custom-provider-registration.d.ts +11 -11
  57. package/dist/provider-api/actions/provider-api.d.ts +9 -9
  58. package/dist/secrets/routes.d.ts +9 -9
  59. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  60. package/dist/server/auth.js +3 -2
  61. package/dist/server/builder-browser.d.ts +0 -4
  62. package/dist/server/builder-browser.js +23 -8
  63. package/dist/server/google-oauth.d.ts +18 -0
  64. package/dist/server/google-oauth.js +3 -3
  65. package/dist/server/realtime-token.d.ts +1 -1
  66. package/dist/server/short-lived-token.d.ts +1 -1
  67. package/dist/server/short-lived-token.js +1 -1
  68. package/dist/server/transcribe-voice.d.ts +1 -1
  69. package/dist/shared/google-profile-image.d.ts +2 -0
  70. package/dist/shared/google-profile-image.js +24 -0
  71. package/dist/shared/index.d.ts +1 -0
  72. package/dist/shared/index.js +1 -0
  73. package/docs/content/creating-templates.mdx +10 -1
  74. package/docs/content/server-database.mdx +46 -2
  75. package/docs/content/server-plugins.mdx +39 -3
  76. package/docs/content/webmcp.mdx +2 -0
  77. package/package.json +3 -2
@@ -1,10 +1,13 @@
1
1
  //! Granola-style adhoc Zoom / Teams detection.
2
2
  //!
3
- //! Polls the frontmost macOS app every few seconds. When Zoom or Teams stays
4
- //! frontmost for a short dwell window *and* that app is holding a live audio
5
- //! input, creates a meeting row via `create-meeting` and shows the same
6
- //! meeting-notification overlay used for calendar reminders — with
7
- //! `type: "adhoc"`.
3
+ //! Samples every known call app every few seconds and asks one question: is a
4
+ //! call underway? A live audio input stream held by Zoom or Teams answers yes
5
+ //! wherever their window sits, so detection survives joining a call and then
6
+ //! working in another app — the common case that foreground dwell alone missed
7
+ //! entirely. Foreground dwell remains the fallback for machines whose OS cannot
8
+ //! report input state at all. On a yes, creates a meeting row via
9
+ //! `create-meeting` (matched to a calendar event when one lines up) and shows
10
+ //! the same meeting-notification overlay used for calendar reminders.
8
11
  //!
9
12
  //! Reuses `MeetingsWatcherState` session (server URL + cookie + auth token)
10
13
  //! so the popover only needs to push credentials once.
@@ -25,17 +28,73 @@ use crate::meetings_watcher::{
25
28
  /// How often to sample the frontmost app.
26
29
  const POLL_SECS: u64 = 2;
27
30
 
28
- /// Require this many consecutive frontmost seconds before firing.
29
- const DWELL_SECS: u64 = 3;
31
+ /// A live input stream held this long is a call, wherever its window sits.
32
+ const MIC_DWELL: Duration = Duration::from_secs(5);
30
33
 
31
- /// After dismiss / fire, suppress re-prompts for this platform until the
32
- /// cooldown elapses (or the VC app leaves front — see tick logic).
33
- const COOLDOWN_SECS: i64 = 30 * 60;
34
+ /// Foreground-only dwell, used when the OS cannot report input state.
35
+ const FRONT_DWELL: Duration = Duration::from_secs(9);
36
+
37
+ /// A call app that just came forward gets this long to open an input stream
38
+ /// before a silent one counts as "not in a call". Joining muted, and the
39
+ /// seconds between the window appearing and the stream starting, both live
40
+ /// here: treating the first silent read as final is how a real call goes
41
+ /// undetected.
42
+ const JOIN_GRACE: Duration = Duration::from_secs(20);
43
+
44
+ /// A live stream that blips out for less than this is still the same call.
45
+ const MIC_DROP_GRACE: Duration = Duration::from_secs(8);
46
+
47
+ /// How long an unreadable input state keeps vouching for a stream that was live
48
+ /// a moment ago. Unreadable is not silence, so it must never end a call — but
49
+ /// it cannot vouch forever either, or a machine whose CoreAudio stopped
50
+ /// answering would hold a finished call open and never prompt again. Past this
51
+ /// the mic has nothing to say and the foreground fallback decides.
52
+ const MIC_UNREADABLE_GRACE: Duration = Duration::from_secs(45);
53
+
54
+ /// No live stream for this long ends the call, which releases the per-call
55
+ /// suppression so the next call gets its own prompt.
56
+ const CALL_END: Duration = Duration::from_secs(30);
57
+
58
+ /// Backstop for the fallback path, where there is no stream to signal the end
59
+ /// of a call. Short enough that a back-to-back block of calls still prompts.
60
+ const COOLDOWN_SECS: i64 = 8 * 60;
34
61
 
35
62
  /// Soft guard: skip adhoc if a calendar reminder for the same platform fired
36
63
  /// this recently.
37
64
  const CALENDAR_SOFT_GUARD_SECS: i64 = 3 * 60;
38
65
 
66
+ /// How long a failed `create-meeting` waits before the same call tries again.
67
+ /// The evidence still says a call is underway, so without a backoff the retry
68
+ /// lands on the next tick and every tick after it.
69
+ const CREATE_RETRY_BACKOFF_SECS: i64 = 60;
70
+
71
+ /// How long an unresolved `create-meeting` attempt keeps forcing a reconcile
72
+ /// before the next attempt for the same call. Bounded on purpose: a doubt
73
+ /// pinned to a call whose end the watcher can no longer observe would
74
+ /// eventually let a *new* call adopt the old call's row. Long enough to cover
75
+ /// several backoff retries of a server outage, short enough that the window in
76
+ /// which that mis-adoption is possible stays narrow.
77
+ const CREATE_DOUBT_TTL_SECS: i64 = 5 * 60;
78
+
79
+ /// Slack on the reconcile window's edges. `scheduledStart` is stamped by this
80
+ /// process but read back through the server, so allow a little clock skew rather
81
+ /// than missing the row we are looking for by one second.
82
+ const RECONCILE_SKEW_SECS: i64 = 10;
83
+
84
+ /// Rows per reconcile page. The agenda view is ascending from the start of the
85
+ /// lookback, so the row a lost response created sits near the front of it; this
86
+ /// only has to exceed the number of meetings that can start inside one
87
+ /// reconcile window. A full page is treated as possibly truncated rather than
88
+ /// assumed complete, so raising this trades a bigger response for fewer
89
+ /// abandoned retries — it is not what makes the lookup correct.
90
+ const RECONCILE_PAGE_LIMIT: usize = 200;
91
+
92
+ /// How many reconcile pages to walk before giving up. The agenda view has no
93
+ /// upper bound, so a user with a long future calendar would otherwise have every
94
+ /// retry page through all of it. Running out of pages is reported as unresolved
95
+ /// rather than as "no such row", so the cap costs a retry, never a duplicate.
96
+ const RECONCILE_MAX_PAGES: usize = 5;
97
+
39
98
  const STRONG_VC_BUNDLES: &[(&str, &str, &str)] = &[
40
99
  // (bundle_id, platform, display title)
41
100
  ("us.zoom.xos", "zoom", "Zoom meeting detected"),
@@ -51,26 +110,380 @@ pub struct AdhocMeetingsWatcherState {
51
110
 
52
111
  #[derive(Default)]
53
112
  struct AdhocMeetingsWatcherInner {
54
- /// platform -> unix-seconds when we last fired (or dismissed via cooldown).
55
- cooldown_until: HashMap<String, i64>,
56
- /// Current dwell: which platform is accumulating frontmost time.
57
- dwell_platform: Option<String>,
58
- dwell_since: Option<Instant>,
59
- /// Platforms already notified for the current continuous foreground session.
113
+ /// platform -> unix-seconds until which an auto-prompt is held back. This
114
+ /// is the backstop for the fallback path, where nothing but the foreground
115
+ /// can say a call is over.
116
+ prompt_cooldown_until: HashMap<String, i64>,
117
+ /// platform -> unix-seconds until which a user dismissal holds. Kept apart
118
+ /// from the prompt backstop because the fallback's coarse "left the window"
119
+ /// signal must not undo a choice the user made on purpose.
120
+ dismissed_until: HashMap<String, i64>,
121
+ /// platform -> what that platform has shown us so far.
122
+ evidence: HashMap<String, CallEvidence>,
123
+ /// Platforms already notified for the current call.
60
124
  session_notified: HashMap<String, bool>,
125
+ /// platform -> unix-seconds of the earliest `create-meeting` attempt for
126
+ /// the current call whose outcome could not be read. A lost response is not
127
+ /// a failed write, so while an entry stands the next attempt must look for
128
+ /// the row the last one may already have committed instead of inserting a
129
+ /// second one. Earliest, not latest: any attempt in the run could be the
130
+ /// one that landed, so the reconcile window has to reach back to the first.
131
+ create_in_doubt_since: HashMap<String, i64>,
132
+ }
133
+
134
+ /// Why a `create-meeting` attempt failed — specifically, whether it could have
135
+ /// committed. Collapsing these two into one error is what turns a lost response
136
+ /// into a duplicate meeting row: the retry cannot tell "the write never
137
+ /// happened" from "the write happened and the answer went missing".
138
+ #[derive(Debug)]
139
+ enum CreateFailure {
140
+ /// Rejected before any insert could run, so nothing was written.
141
+ NotCommitted(String),
142
+ /// Unreadable outcome: the row may or may not exist.
143
+ Ambiguous(String),
144
+ }
145
+
146
+ impl CreateFailure {
147
+ fn message(&self) -> &str {
148
+ match self {
149
+ CreateFailure::NotCommitted(m) | CreateFailure::Ambiguous(m) => m,
150
+ }
151
+ }
152
+
153
+ fn is_ambiguous(&self) -> bool {
154
+ matches!(self, CreateFailure::Ambiguous(_))
155
+ }
156
+ }
157
+
158
+ /// Whether a call is underway on one platform.
159
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
160
+ enum CallState {
161
+ /// Confirmed: prompt for it.
162
+ Live,
163
+ /// Might still become a call — hold the accumulated evidence.
164
+ Pending,
165
+ /// No call, and no reason to keep waiting.
166
+ Idle,
167
+ }
168
+
169
+ /// What one platform has shown us across the current run of ticks.
170
+ #[derive(Debug, Default, Clone, Copy)]
171
+ struct CallEvidence {
172
+ /// When it became frontmost, in the current unbroken foreground run.
173
+ front_since: Option<Instant>,
174
+ /// When its live input stream first appeared in the current call.
175
+ mic_since: Option<Instant>,
176
+ /// The most recent tick that saw a live input stream.
177
+ mic_last_true: Option<Instant>,
178
+ /// Start of the current unbroken run of reads that say there is no stream.
179
+ /// Only `Some(false)` lands here. An unreadable read is not evidence of
180
+ /// silence, and silence is the only thing allowed to end a call.
181
+ mic_silent_since: Option<Instant>,
182
+ /// Start of the current unbroken run of unreadable (`None`) reads.
183
+ mic_unreadable_since: Option<Instant>,
184
+ /// Set at a call boundary, reported once by `take_call_ended`.
185
+ call_ended: bool,
186
+ /// Set once when a readable stream stops being able to speak for the call,
187
+ /// reported by `take_mic_abstained`.
188
+ mic_abstained: bool,
189
+ /// Whether the stream was the authority as of the previous tick, so the
190
+ /// moment it stops being one can be caught as an edge rather than a state.
191
+ mic_was_authoritative: bool,
192
+ }
193
+
194
+ impl CallEvidence {
195
+ fn observe(&mut self, now: Instant, mic: Option<bool>, frontmost: bool) {
196
+ if frontmost {
197
+ self.front_since.get_or_insert(now);
198
+ } else {
199
+ self.front_since = None;
200
+ }
201
+ match mic {
202
+ Some(true) => {
203
+ // A stream returning inside `CALL_END` is the same call coming
204
+ // back, not the next one. `MIC_DROP_GRACE` having cleared
205
+ // `mic_since` only restarts the dwell; treating that as a new
206
+ // call would prompt a second time for one ordinary blip.
207
+ self.mic_last_true = Some(now);
208
+ self.mic_since.get_or_insert(now);
209
+ self.mic_silent_since = None;
210
+ self.mic_unreadable_since = None;
211
+ }
212
+ Some(false) => {
213
+ self.mic_unreadable_since = None;
214
+ let silent_since = *self.mic_silent_since.get_or_insert(now);
215
+ let silent_for = now.saturating_duration_since(silent_since);
216
+ if silent_for >= MIC_DROP_GRACE {
217
+ self.mic_since = None;
218
+ }
219
+ if self.mic_last_true.is_some() && silent_for >= CALL_END {
220
+ self.end_call(now);
221
+ }
222
+ }
223
+ // The OS could not answer. That is not a "no": the stream timers
224
+ // stay where they are so an unreadable stretch neither starts a
225
+ // call nor ends one. The silence run does break, though — time we
226
+ // could not read is not time we watched go quiet, and counting it
227
+ // would let one `Some(false)` afterwards satisfy `CALL_END` on its
228
+ // own and end a call that may never have stopped.
229
+ None => {
230
+ self.mic_unreadable_since.get_or_insert(now);
231
+ self.mic_silent_since = None;
232
+ }
233
+ }
234
+ let authoritative = self.mic_is_authoritative(now);
235
+ if self.mic_was_authoritative && !authoritative {
236
+ self.mic_abstained = true;
237
+ }
238
+ self.mic_was_authoritative = authoritative;
239
+ }
240
+
241
+ /// Close the current call: report the boundary once, and drop the evidence
242
+ /// that only describes the call that just ended.
243
+ fn end_call(&mut self, now: Instant) {
244
+ self.call_ended = true;
245
+ self.mic_since = None;
246
+ self.mic_last_true = None;
247
+ self.mic_silent_since = None;
248
+ // A call that ended on the stream's own verdict has not lost its
249
+ // authority — it exercised it. Only an unreadable stretch abstains.
250
+ self.mic_was_authoritative = false;
251
+ // The next call may start muted in this same still-frontmost window.
252
+ // An untouched foreground age would be past `JOIN_GRACE` already and
253
+ // read as a window merely parked open, so re-arm the grace here.
254
+ if self.front_since.is_some() {
255
+ self.front_since = Some(now);
256
+ }
257
+ }
258
+
259
+ /// Whether a stream has spoken for this platform at all in the current
260
+ /// call. While one has, the window is not evidence about anything.
261
+ fn mic_ever_spoke(&self) -> bool {
262
+ self.mic_last_true.is_some() || self.mic_since.is_some()
263
+ }
264
+
265
+ /// Whether the stream is still the authority on when this call ends. It is,
266
+ /// right up until an unreadable run outlives its grace — at which point we
267
+ /// genuinely do not know, and the coarse foreground signal is all that is
268
+ /// left. Holding "authoritative" through an unbounded unreadable stretch is
269
+ /// what would strand a platform: the flag that suppression hangs on has no
270
+ /// clock of its own, so nothing would ever release it.
271
+ fn mic_is_authoritative(&self, now: Instant) -> bool {
272
+ self.mic_ever_spoke()
273
+ && !self
274
+ .mic_unreadable_since
275
+ .is_some_and(|since| now.saturating_duration_since(since) >= MIC_UNREADABLE_GRACE)
276
+ }
277
+
278
+ fn mic_live(&self, now: Instant, mic: Option<bool>) -> bool {
279
+ if mic == Some(true) {
280
+ return true;
281
+ }
282
+ // Only an established stream earns a grace period. A stream seen on one
283
+ // tick and gone on the next is a device probe or a notification sound;
284
+ // bridging that gap would let `mic_since` age past `MIC_DWELL` while
285
+ // nothing was running and confirm a call that never happened.
286
+ let (Some(since), Some(last)) = (self.mic_since, self.mic_last_true) else {
287
+ return false;
288
+ };
289
+ if last.saturating_duration_since(since) < MIC_DWELL {
290
+ return false;
291
+ }
292
+ if self
293
+ .mic_silent_since
294
+ .is_some_and(|since| now.saturating_duration_since(since) >= MIC_DROP_GRACE)
295
+ {
296
+ return false;
297
+ }
298
+ !self
299
+ .mic_unreadable_since
300
+ .is_some_and(|since| now.saturating_duration_since(since) >= MIC_UNREADABLE_GRACE)
301
+ }
302
+
303
+ fn classify(&self, now: Instant, mic: Option<bool>, frontmost: bool) -> CallState {
304
+ if self.mic_live(now, mic) {
305
+ let since = self.mic_since.unwrap_or(now);
306
+ return if now.saturating_duration_since(since) >= MIC_DWELL {
307
+ CallState::Live
308
+ } else {
309
+ CallState::Pending
310
+ };
311
+ }
312
+ let Some(front_since) = self.front_since.filter(|_| frontmost) else {
313
+ return CallState::Idle;
314
+ };
315
+ let front_for = now.saturating_duration_since(front_since);
316
+ match mic {
317
+ // The OS declined to answer — `kAudioProcessPropertyIsRunningInput`
318
+ // is macOS 14+ — so foreground dwell is the only evidence there is.
319
+ // Requiring a stream here would leave older machines detecting
320
+ // nothing at all.
321
+ None => {
322
+ if front_for >= FRONT_DWELL {
323
+ CallState::Live
324
+ } else {
325
+ CallState::Pending
326
+ }
327
+ }
328
+ // A definite no. Wait out the join grace, then stop: past it, a
329
+ // silent Zoom window is Zoom parked open, which is the noise that
330
+ // got prompting muted in the first place.
331
+ _ => {
332
+ if front_for < JOIN_GRACE {
333
+ CallState::Pending
334
+ } else {
335
+ CallState::Idle
336
+ }
337
+ }
338
+ }
339
+ }
340
+
341
+ /// True once per call boundary: either a confirmed run of silence long
342
+ /// enough to be the end of the call, or a fresh stream that can only be the
343
+ /// next one. An unreadable input state is neither, so it never fires here —
344
+ /// releasing the per-call suppression on a state we could not read is how
345
+ /// the same call gets prompted twice.
346
+ fn take_call_ended(&mut self) -> bool {
347
+ std::mem::take(&mut self.call_ended)
348
+ }
349
+
350
+ /// True once, at the moment a readable stream becomes unreadable for longer
351
+ /// than its grace. Not a call end — we do not know that — but a change of
352
+ /// regime, after which only the foreground has anything to say.
353
+ fn take_mic_abstained(&mut self) -> bool {
354
+ std::mem::take(&mut self.mic_abstained)
355
+ }
61
356
  }
62
357
 
63
358
  impl AdhocMeetingsWatcherInner {
64
- fn refresh_suppression(&mut self, platform: &str, now_ts: i64) {
359
+ fn note_prompted(&mut self, platform: &str, now_ts: i64) {
360
+ self.session_notified.insert(platform.to_string(), true);
361
+ self.prompt_cooldown_until
362
+ .insert(platform.to_string(), now_ts + COOLDOWN_SECS);
363
+ }
364
+
365
+ fn note_dismissed(&mut self, platform: &str, now_ts: i64) {
65
366
  self.session_notified.insert(platform.to_string(), true);
66
- self.cooldown_until
367
+ self.dismissed_until
67
368
  .insert(platform.to_string(), now_ts + COOLDOWN_SECS);
68
- self.dwell_platform = None;
69
- self.dwell_since = None;
369
+ }
370
+
371
+ /// The current call is confirmed over: prompt again for the next one, even
372
+ /// if the user dismissed this one.
373
+ fn end_call_session(&mut self, platform: &str) {
374
+ self.session_notified.remove(platform);
375
+ self.prompt_cooldown_until.remove(platform);
376
+ self.dismissed_until.remove(platform);
377
+ // The doubt belonged to the call that just ended. Carrying it into the
378
+ // next call would make that call adopt the previous call's row.
379
+ self.create_in_doubt_since.remove(platform);
380
+ }
381
+
382
+ /// A create failed. Let the same call try again, but not on the next tick:
383
+ /// the evidence is untouched, so it reconfirms immediately.
384
+ ///
385
+ /// `ambiguous` is the part that matters. Backoff alone cannot make a
386
+ /// non-idempotent create safe — it only decides *when* the duplicate is
387
+ /// inserted — so an attempt that may have committed has to be remembered
388
+ /// and reconciled before the next one runs.
389
+ fn note_create_failed(&mut self, platform: &str, now_ts: i64, ambiguous: bool) {
390
+ self.session_notified.remove(platform);
391
+ self.prompt_cooldown_until
392
+ .insert(platform.to_string(), now_ts + CREATE_RETRY_BACKOFF_SECS);
393
+ if ambiguous {
394
+ self.create_in_doubt_since
395
+ .entry(platform.to_string())
396
+ .or_insert(now_ts);
397
+ }
398
+ }
399
+
400
+ /// A create resolved: there is a row, and we know its id.
401
+ fn note_create_resolved(&mut self, platform: &str) {
402
+ self.create_in_doubt_since.remove(platform);
403
+ }
404
+
405
+ /// Hold back the platforms that lost this poll's tie-break, now that the
406
+ /// selected platform has actually produced a meeting row.
407
+ ///
408
+ /// Deliberately not a prompt cooldown: these platforms were never prompted
409
+ /// for. Giving them one would mute a live call for eight minutes on the
410
+ /// strength of a different call winning a coin toss, which is the same
411
+ /// defect one layer down. The bare flag is released by the platform's own
412
+ /// call end — or, on the fallback path, by the boundaries that keep it from
413
+ /// stranding — and either way what follows is the prompt it never got.
414
+ fn defer_secondary_candidates(&mut self, platforms: &[&str]) {
415
+ for platform in platforms {
416
+ self.session_notified.insert((*platform).to_string(), true);
417
+ }
418
+ }
419
+
420
+ /// The attempt timestamp a retry for this platform must reconcile against,
421
+ /// or `None` when no attempt is outstanding.
422
+ fn create_doubt_since(&self, platform: &str) -> Option<i64> {
423
+ self.create_in_doubt_since.get(platform).copied()
424
+ }
425
+
426
+ /// Stop tracking calls entirely: the feature is off, or a meeting is
427
+ /// already being transcribed.
428
+ ///
429
+ /// The evidence that would have released `session_notified` is going away,
430
+ /// so that flag cannot stay as it is — nothing would ever clear it. It also
431
+ /// cannot simply be dropped: transcription can stop while the call and its
432
+ /// stream keep running, and a blank slate re-detects that call within one
433
+ /// `MIC_DWELL` and prompts for it again. So it becomes the cooldown, which
434
+ /// is bounded: a call that outlives transcription stays suppressed, and a
435
+ /// platform is never suppressed for longer than the cooldown. A dismissal
436
+ /// already has its own clock and is left alone.
437
+ fn clear_tracking(&mut self, now_ts: i64) {
438
+ self.evidence.clear();
439
+ // Whatever call the doubt belonged to, we are no longer tracking it, so
440
+ // there is nothing left to tie a found row to.
441
+ self.create_in_doubt_since.clear();
442
+ let notified: Vec<String> = self.session_notified.drain().map(|(p, _)| p).collect();
443
+ for platform in notified {
444
+ let until = now_ts + COOLDOWN_SECS;
445
+ self.prompt_cooldown_until
446
+ .entry(platform)
447
+ .and_modify(|existing| *existing = (*existing).max(until))
448
+ .or_insert(until);
449
+ }
450
+ }
451
+
452
+ fn retain_live_cooldowns(&mut self, now_ts: i64) {
453
+ self.prompt_cooldown_until
454
+ .retain(|_, until| *until > now_ts);
455
+ self.dismissed_until.retain(|_, until| *until > now_ts);
456
+
457
+ // An attempt we never managed to settle must not quietly become a fresh
458
+ // insert. Dropping the marker alone would do exactly that: the next tick
459
+ // would find no doubt, skip the reconcile, and bare-insert a row that may
460
+ // already exist. So expiry hands the platform a normal cooldown instead
461
+ // — the write we cannot see stays un-duplicated, and the platform is
462
+ // picked up again after it, by which point a new attempt is far more
463
+ // likely to be a genuinely new call than a retry of this one.
464
+ let expired: Vec<String> = self
465
+ .create_in_doubt_since
466
+ .iter()
467
+ .filter(|(_, since)| *since + CREATE_DOUBT_TTL_SECS <= now_ts)
468
+ .map(|(platform, _)| platform.clone())
469
+ .collect();
470
+ for platform in expired {
471
+ self.create_in_doubt_since.remove(&platform);
472
+ let until = now_ts + COOLDOWN_SECS;
473
+ self.prompt_cooldown_until
474
+ .entry(platform)
475
+ .and_modify(|existing| *existing = (*existing).max(until))
476
+ .or_insert(until);
477
+ }
70
478
  }
71
479
 
72
480
  fn is_suppressed(&self, platform: &str, now_ts: i64) -> bool {
73
- self.cooldown_until.get(platform).copied().unwrap_or(0) > now_ts
481
+ self.prompt_cooldown_until
482
+ .get(platform)
483
+ .copied()
484
+ .unwrap_or(0)
485
+ > now_ts
486
+ || self.dismissed_until.get(platform).copied().unwrap_or(0) > now_ts
74
487
  || self
75
488
  .session_notified
76
489
  .get(platform)
@@ -88,7 +501,7 @@ pub fn refresh_dismissal_suppression(app: &AppHandle, platform: &str) -> Result<
88
501
  .try_state::<AdhocMeetingsWatcherState>()
89
502
  .ok_or_else(|| "no AdhocMeetingsWatcherState".to_string())?;
90
503
  let mut g = state.inner.lock().map_err(|e| e.to_string())?;
91
- g.refresh_suppression(&platform, chrono::Utc::now().timestamp());
504
+ g.note_dismissed(&platform, chrono::Utc::now().timestamp());
92
505
  Ok(())
93
506
  }
94
507
 
@@ -133,6 +546,17 @@ fn match_vc_bundle(bundle: &str) -> Option<(&'static str, &'static str)> {
133
546
  .map(|(_, platform, title)| (*platform, *title))
134
547
  }
135
548
 
549
+ /// One entry per platform, with the title its notification falls back to.
550
+ fn platform_titles() -> Vec<(&'static str, &'static str)> {
551
+ let mut out: Vec<(&'static str, &'static str)> = Vec::new();
552
+ for (_, platform, title) in STRONG_VC_BUNDLES {
553
+ if !out.iter().any(|(known, _)| known == platform) {
554
+ out.push((*platform, *title));
555
+ }
556
+ }
557
+ out
558
+ }
559
+
136
560
  /// Bundle ids belonging to one platform, lowercased for CoreAudio comparison.
137
561
  ///
138
562
  /// Scoped to the frontmost platform rather than every call app: Teams sitting
@@ -145,16 +569,6 @@ fn bundles_for_platform(platform: &str) -> Vec<String> {
145
569
  .collect()
146
570
  }
147
571
 
148
- /// Whether a live-input reading should veto a dwell-confirmed detection.
149
- ///
150
- /// Only an explicit `Some(false)` blocks. `None` means the OS declined to
151
- /// answer — `kAudioProcessPropertyIsRunningInput` is macOS 14+ — and treating
152
- /// that as "no call" would leave every older machine detecting nothing at all,
153
- /// which is the failure mode this whole branch exists to undo.
154
- fn mic_vetoes_detection(mic_running: Option<bool>) -> bool {
155
- mic_running == Some(false)
156
- }
157
-
158
572
  /// What a dwell-confirmed detection is allowed to do under the current mode.
159
573
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
160
574
  struct AdhocNotificationPlan {
@@ -180,7 +594,7 @@ fn adhoc_notification_plan(config: &crate::config::FeatureConfig) -> AdhocNotifi
180
594
  async fn tick_once(app: &AppHandle, client: &reqwest::Client) -> Result<(), String> {
181
595
  let config = feature_config(app);
182
596
  if !config.meetings_enabled {
183
- reset_dwell(app);
597
+ reset_evidence(app);
184
598
  return Ok(());
185
599
  }
186
600
 
@@ -202,45 +616,101 @@ async fn tick_macos(
202
616
  client: &reqwest::Client,
203
617
  config: &crate::config::FeatureConfig,
204
618
  ) -> Result<(), String> {
205
- let front = crate::util::frontmost_bundle_id();
206
- let matched = front.as_deref().and_then(match_vc_bundle);
207
-
208
- let Some((platform, fallback_title)) = matched else {
209
- // VC app left front — clear session dedupe so a later re-focus can
210
- // fire again (cooldown still applies).
211
- clear_session_if_left(app, None);
212
- reset_dwell(app);
213
- return Ok(());
214
- };
215
-
216
- clear_session_if_left(app, Some(platform));
217
-
218
619
  // Skip while already transcribing a meeting.
219
620
  if crate::util::is_meeting_active(app) {
220
- reset_dwell(app);
621
+ reset_evidence(app);
221
622
  return Ok(());
222
623
  }
223
624
 
224
625
  if config.meeting_transcription_mode == MeetingTranscriptionMode::Manual
225
626
  && !config.show_meeting_widget_enabled
226
627
  {
227
- reset_dwell(app);
628
+ reset_evidence(app);
228
629
  return Ok(());
229
630
  }
230
631
 
231
- // Frontmost dwell alone also matches Zoom parked on a second monitor or
232
- // opened to check a schedule — the reason prompting was muted in the first
233
- // place. A live input stream is what separates an app being open from a
234
- // call being underway. `None` means the OS could not answer (the property
235
- // is macOS 14+), so fall back to dwell-only rather than going silently
236
- // dead on older systems.
237
- let call_bundles = bundles_for_platform(platform);
238
- let mic_running = crate::call_activity::call_app_uses_microphone(&call_bundles);
239
- if mic_vetoes_detection(mic_running) {
240
- reset_dwell(app);
241
- return Ok(());
632
+ let front = crate::util::frontmost_bundle_id();
633
+ let front_platform = front.as_deref().and_then(match_vc_bundle).map(|(p, _)| p);
634
+ let now = Instant::now();
635
+ let now_ts = chrono::Utc::now().timestamp();
636
+
637
+ // Read every platform, not just the frontmost one. A call holds its input
638
+ // stream open while you take notes elsewhere, read Slack, or share a doc,
639
+ // so that stream is the evidence that survives leaving the meeting window.
640
+ let mut confirmed: Option<(&'static str, &'static str)> = None;
641
+ let mut deferred: Vec<&'static str> = Vec::new();
642
+ for (platform, fallback) in platform_titles() {
643
+ let mic = crate::call_activity::call_app_uses_microphone(&bundles_for_platform(platform));
644
+ let frontmost = front_platform == Some(platform);
645
+
646
+ let state = app
647
+ .try_state::<AdhocMeetingsWatcherState>()
648
+ .ok_or_else(|| "no AdhocMeetingsWatcherState".to_string())?;
649
+ let mut g = state.inner.lock().map_err(|e| e.to_string())?;
650
+ g.retain_live_cooldowns(now_ts);
651
+
652
+ let (call_state, call_ended, mic_abstained, mic_live, mic_authoritative) = {
653
+ let evidence = g.evidence.entry(platform.to_string()).or_default();
654
+ evidence.observe(now, mic, frontmost);
655
+ (
656
+ evidence.classify(now, mic, frontmost),
657
+ evidence.take_call_ended(),
658
+ evidence.take_mic_abstained(),
659
+ evidence.mic_live(now, mic),
660
+ evidence.mic_is_authoritative(now),
661
+ )
662
+ };
663
+
664
+ // A finished call releases every suppression, so the next call in a
665
+ // back-to-back block gets its own prompt instead of inheriting one.
666
+ // This is the stream's verdict, and the only thing that clears the
667
+ // cooldown: nothing below can prove a call ended, only that we stopped
668
+ // being able to see it.
669
+ if call_ended {
670
+ g.end_call_session(platform);
671
+ }
672
+ // `session_notified` is the flag with no clock of its own, so it needs
673
+ // releasing on evidence weaker than a call end or it strands the
674
+ // platform. Both releases below leave the prompt cooldown standing,
675
+ // which is what bounds a duplicate prompt in the meantime.
676
+ //
677
+ // Losing the window is not proof a call ended — on the fallback path,
678
+ // switching to Slack mid-call looks exactly the same — so it may only
679
+ // release the flag while the stream is not speaking for the call.
680
+ if !mic_live && !frontmost && !mic_authoritative {
681
+ g.session_notified.remove(platform);
682
+ }
683
+ // The stream just stopped being readable. The window may never change
684
+ // again, so this edge is the only chance to hand the session to the
685
+ // fallback; without it a later call in a still-frontmost window has no
686
+ // boundary left that could ever release the flag.
687
+ if mic_abstained {
688
+ g.session_notified.remove(platform);
689
+ }
690
+
691
+ if call_state == CallState::Live && !g.is_suppressed(platform, now_ts) {
692
+ if confirmed.is_none() {
693
+ confirmed = Some((platform, fallback));
694
+ } else {
695
+ // One poll prompts for one call. A second platform holding a
696
+ // stream at the same moment would otherwise be untouched here
697
+ // and confirm on the very next tick, so a single moment of
698
+ // overlap becomes a second meeting two seconds later.
699
+ //
700
+ // Only note it, though — suppressing it here would spend the
701
+ // selected platform's failure on it. If the selected flow then
702
+ // errors or is skipped by the calendar guard, the platform that
703
+ // lost the tie is the only live call left, and marking it
704
+ // already would have hidden it for the rest of its run.
705
+ deferred.push(platform);
706
+ }
707
+ }
242
708
  }
243
709
 
710
+ let Some((platform, fallback_title)) = confirmed else {
711
+ return Ok(());
712
+ };
713
+
244
714
  // Soft guard against double-prompting after a calendar reminder.
245
715
  if let Some(state) = app.try_state::<MeetingsWatcherState>() {
246
716
  if state.recent_calendar_notify(platform, CALENDAR_SOFT_GUARD_SECS) {
@@ -248,82 +718,93 @@ async fn tick_macos(
248
718
  "[clips-tray] adhoc skip: recent calendar notify for {}",
249
719
  platform
250
720
  );
251
- reset_dwell(app);
721
+ // The calendar reminder owns this call, so record it as handled
722
+ // rather than just skipping the tick. Leaving it unmarked keeps the
723
+ // call eligible on every later tick, and the moment the guard
724
+ // expires the same call prompts again — the double-prompt this
725
+ // guard exists to prevent, three minutes late.
726
+ //
727
+ // Recorded exactly like a prompt, cooldown included. The flag alone
728
+ // is released by the window changing, which on a machine with no
729
+ // readable stream is just the user checking Slack — so without the
730
+ // cooldown the guard lasts only until they come back.
731
+ if let Some(adhoc) = app.try_state::<AdhocMeetingsWatcherState>() {
732
+ if let Ok(mut g) = adhoc.inner.lock() {
733
+ g.note_prompted(platform, now_ts);
734
+ }
735
+ }
252
736
  return Ok(());
253
737
  }
254
738
  }
255
739
 
256
- let now_ts = chrono::Utc::now().timestamp();
257
- let should_fire = {
740
+ let reconcile_since = {
258
741
  let state = app
259
742
  .try_state::<AdhocMeetingsWatcherState>()
260
743
  .ok_or_else(|| "no AdhocMeetingsWatcherState".to_string())?;
261
744
  let mut g = state.inner.lock().map_err(|e| e.to_string())?;
262
-
263
- // Prune expired cooldowns.
264
- g.cooldown_until.retain(|_, until| *until > now_ts);
265
-
266
- if g.is_suppressed(platform, now_ts) {
267
- return Ok(());
268
- }
269
-
270
- match (g.dwell_platform.as_deref(), g.dwell_since) {
271
- (Some(p), Some(since)) if p == platform => {
272
- if since.elapsed() >= Duration::from_secs(DWELL_SECS) {
273
- g.session_notified.insert(platform.to_string(), true);
274
- g.cooldown_until
275
- .insert(platform.to_string(), now_ts + COOLDOWN_SECS);
276
- g.dwell_platform = None;
277
- g.dwell_since = None;
278
- true
279
- } else {
280
- false
281
- }
282
- }
283
- _ => {
284
- g.dwell_platform = Some(platform.to_string());
285
- g.dwell_since = Some(Instant::now());
286
- false
287
- }
288
- }
745
+ g.note_prompted(platform, now_ts);
746
+ g.create_doubt_since(platform)
289
747
  };
290
748
 
291
- if !should_fire {
292
- return Ok(());
293
- }
294
-
295
749
  dlog!(
296
- "[clips-tray] adhoc dwell met for {} — creating meeting",
750
+ "[clips-tray] adhoc call confirmed for {} — creating meeting",
297
751
  platform
298
752
  );
299
753
 
300
- let meeting = match create_adhoc_meeting(app, client, platform).await {
754
+ let meeting = match create_adhoc_meeting(app, client, platform, reconcile_since).await {
301
755
  Ok(meeting) => meeting,
302
- Err(err) => {
303
- // Allow retry on next dwell if create failed.
756
+ Err(failure) => {
757
+ // Retry, but not on the very next tick. The evidence is untouched,
758
+ // so the call reconfirms immediately and would re-submit every two
759
+ // seconds for as long as the call runs. Backoff only spaces the
760
+ // attempts out, though — an attempt whose outcome we could not read
761
+ // is recorded as such so the next one reconciles instead of
762
+ // inserting a second row for the same call.
304
763
  if let Some(state) = app.try_state::<AdhocMeetingsWatcherState>() {
305
764
  if let Ok(mut g) = state.inner.lock() {
306
- g.session_notified.remove(platform);
307
- g.cooldown_until.remove(platform);
765
+ g.note_create_failed(platform, now_ts, failure.is_ambiguous());
308
766
  }
309
767
  }
310
- return Err(err);
768
+ return Err(failure.message().to_string());
311
769
  }
312
770
  };
313
771
 
772
+ // The selected flow produced a row, so the tie-break above is now settled
773
+ // and the platforms that lost it can be held back. Their own call end
774
+ // releases them again.
775
+ if !deferred.is_empty() {
776
+ if let Some(state) = app.try_state::<AdhocMeetingsWatcherState>() {
777
+ if let Ok(mut g) = state.inner.lock() {
778
+ g.defer_secondary_candidates(&deferred);
779
+ }
780
+ }
781
+ }
782
+
783
+ {
784
+ let state = app
785
+ .try_state::<AdhocMeetingsWatcherState>()
786
+ .ok_or_else(|| "no AdhocMeetingsWatcherState".to_string())?;
787
+ let mut g = state.inner.lock().map_err(|e| e.to_string())?;
788
+ g.note_create_resolved(platform);
789
+ }
790
+
314
791
  let AdhocNotificationPlan {
315
792
  show_widget,
316
793
  auto_start,
317
794
  } = adhoc_notification_plan(config);
318
795
 
796
+ // Awaited, not spawned. `meetings:hide-notification` is dropped by the
797
+ // overlay unless it already holds the matching payload, so auto-start
798
+ // emitting first would race the card into existence *after* the only event
799
+ // that clears it — leaving a "Take notes?" prompt stuck over a meeting that
800
+ // is already recording. Installing the payload before startup is announced
801
+ // makes that ordering impossible rather than unlikely.
319
802
  if show_widget {
320
- let app_clone = app.clone();
321
- let id_clone = meeting.id.clone();
322
- let title_clone = meeting
803
+ let title = meeting
323
804
  .title
324
805
  .clone()
325
806
  .unwrap_or_else(|| fallback_title.to_string());
326
- let platform_clone = meeting
807
+ let notify_platform = meeting
327
808
  .platform
328
809
  .clone()
329
810
  .unwrap_or_else(|| platform.to_string());
@@ -331,24 +812,26 @@ async fn tick_macos(
331
812
  .scheduled_start
332
813
  .clone()
333
814
  .or_else(|| Some(chrono::Utc::now().to_rfc3339()));
334
- let scheduled_end = meeting.scheduled_end.clone();
335
- let join_url = meeting.join_url.clone();
336
- let source = meeting.source.clone().or_else(|| Some("adhoc".to_string()));
337
- tauri::async_runtime::spawn(async move {
338
- let _ = crate::notifications::notify_meeting_starting(
339
- app_clone,
340
- id_clone,
341
- title_clone,
342
- 0,
343
- join_url,
344
- scheduled_start,
345
- scheduled_end,
346
- Some(platform_clone),
347
- Some(auto_start),
348
- source,
349
- )
350
- .await;
351
- });
815
+ if let Err(err) = crate::notifications::notify_meeting_starting(
816
+ app.clone(),
817
+ meeting.id.clone(),
818
+ title,
819
+ 0,
820
+ meeting.join_url.clone(),
821
+ scheduled_start,
822
+ meeting.scheduled_end.clone(),
823
+ Some(notify_platform),
824
+ Some(auto_start),
825
+ meeting.source.clone().or_else(|| Some("adhoc".to_string())),
826
+ )
827
+ .await
828
+ {
829
+ dlog!(
830
+ "[clips-tray] adhoc notification failed for {}: {}",
831
+ meeting.id,
832
+ err
833
+ );
834
+ }
352
835
  }
353
836
 
354
837
  if auto_start {
@@ -361,6 +844,12 @@ async fn tick_macos(
361
844
  "scheduledStart": meeting.scheduled_start,
362
845
  }),
363
846
  );
847
+ // The stored payload is deliberately left alone here. Emitting only
848
+ // *requests* startup — audio acquisition happens afterwards and can
849
+ // still fail — so clearing it now would leave a cold overlay with
850
+ // nothing to hydrate and the user with no way back to the detected
851
+ // meeting. `watch_meeting_notification_acks` retires it when the
852
+ // frontend confirms the meeting is on screen instead.
364
853
  }
365
854
 
366
855
  Ok(())
@@ -395,124 +884,993 @@ mod tests {
395
884
  assert!(bundles_for_platform("webex").is_empty());
396
885
  }
397
886
 
887
+ /// `Instant` has no constructor, and subtracting past boot panics, so age
888
+ /// every fixture from a single `now` and clamp.
889
+ fn ago(now: Instant, secs: u64) -> Instant {
890
+ now.checked_sub(Duration::from_secs(secs)).unwrap_or(now)
891
+ }
892
+
398
893
  #[test]
399
- fn an_unreadable_microphone_does_not_veto_detection() {
400
- // Only a definite "not in a call" blocks. Collapsing None into false
401
- // would make ad-hoc detection dead on macOS 13, where the CoreAudio
402
- // property does not exist — the exact silent-death this branch undoes.
403
- assert!(mic_vetoes_detection(Some(false)));
404
- assert!(!mic_vetoes_detection(Some(true)));
405
- assert!(!mic_vetoes_detection(None));
894
+ fn every_platform_is_sampled_once() {
895
+ let platforms = platform_titles();
896
+ assert_eq!(platforms.len(), 2);
897
+ assert!(platforms.iter().any(|(p, _)| *p == "zoom"));
898
+ assert!(platforms.iter().any(|(p, _)| *p == "teams"));
406
899
  }
407
900
 
408
901
  #[test]
409
- fn ask_mode_prompts_without_auto_starting() {
410
- // The b00c38db4 regression: Ask is the shipped default, so if this
411
- // stops surfacing the overlay, ad-hoc detection is dead for everyone
412
- // who never opened Settings.
413
- let plan = adhoc_notification_plan(&config_with(MeetingTranscriptionMode::Ask, false));
414
- assert!(plan.show_widget);
415
- assert!(!plan.auto_start);
902
+ fn a_live_stream_confirms_a_call_from_the_background() {
903
+ // The whole point: join a call, switch to Slack, and detection still
904
+ // lands. Foreground dwell alone missed this entirely.
905
+ let now = Instant::now();
906
+ let evidence = CallEvidence {
907
+ front_since: None,
908
+ mic_since: Some(ago(now, 6)),
909
+ mic_last_true: Some(now),
910
+ ..Default::default()
911
+ };
912
+ assert_eq!(evidence.classify(now, Some(true), false), CallState::Live);
416
913
  }
417
914
 
418
915
  #[test]
419
- fn auto_mode_prompts_and_starts() {
420
- let plan = adhoc_notification_plan(&config_with(MeetingTranscriptionMode::Auto, false));
421
- assert!(plan.show_widget);
422
- assert!(plan.auto_start);
916
+ fn a_live_stream_still_debounces() {
917
+ let now = Instant::now();
918
+ let evidence = CallEvidence {
919
+ front_since: None,
920
+ mic_since: Some(ago(now, 2)),
921
+ mic_last_true: Some(now),
922
+ ..Default::default()
923
+ };
924
+ assert_eq!(
925
+ evidence.classify(now, Some(true), false),
926
+ CallState::Pending
927
+ );
423
928
  }
424
929
 
425
930
  #[test]
426
- fn manual_mode_stays_silent_only_when_the_widget_is_disabled() {
427
- let hidden = adhoc_notification_plan(&config_with(MeetingTranscriptionMode::Manual, false));
428
- assert!(!hidden.show_widget);
429
- assert!(!hidden.auto_start);
931
+ fn a_single_live_sample_is_not_a_call() {
932
+ // A notification sound or a device probe holds the input for one tick.
933
+ // The drop grace used to bridge it while `mic_since` kept ageing, so at
934
+ // t=6 the dwell looked satisfied and a meeting was created for a stream
935
+ // that ran for two seconds.
936
+ let now = Instant::now();
937
+ let mut evidence = CallEvidence::default();
938
+ evidence.observe(ago(now, 6), Some(true), false);
939
+ evidence.observe(ago(now, 4), Some(false), false);
940
+ evidence.observe(ago(now, 2), Some(false), false);
941
+ evidence.observe(now, Some(false), false);
942
+
943
+ assert!(!evidence.mic_live(now, Some(false)));
944
+ assert_eq!(
945
+ evidence.classify(now, Some(false), false),
946
+ CallState::Idle,
947
+ "one live sample never becomes a confirmed call"
948
+ );
949
+ }
430
950
 
431
- let shown = adhoc_notification_plan(&config_with(MeetingTranscriptionMode::Manual, true));
432
- assert!(shown.show_widget);
433
- assert!(!shown.auto_start);
951
+ #[test]
952
+ fn an_established_stream_still_gets_its_drop_grace() {
953
+ // The counterpart: once a stream has run long enough to be confirmed, a
954
+ // short gap must not drop it back out.
955
+ let now = Instant::now();
956
+ let mut evidence = CallEvidence::default();
957
+ evidence.observe(ago(now, 20), Some(true), false);
958
+ evidence.observe(ago(now, 14), Some(true), false);
959
+ evidence.observe(now, Some(false), false);
960
+
961
+ assert!(evidence.mic_live(now, Some(false)));
962
+ assert_eq!(evidence.classify(now, Some(false), false), CallState::Live);
434
963
  }
435
964
 
436
965
  #[test]
437
- fn dismissal_refreshes_the_existing_cooldown_and_clears_dwell() {
438
- let now_ts = 1_000;
439
- let mut state = AdhocMeetingsWatcherInner {
440
- dwell_platform: Some("zoom".to_string()),
441
- dwell_since: Some(Instant::now()),
966
+ fn a_stream_that_blips_out_does_not_restart_the_dwell() {
967
+ let now = Instant::now();
968
+ let mut evidence = CallEvidence::default();
969
+ evidence.observe(ago(now, 20), Some(true), false);
970
+ evidence.observe(ago(now, 4), Some(true), false);
971
+ evidence.observe(now, Some(false), false);
972
+ assert_eq!(
973
+ evidence.classify(now, Some(false), false),
974
+ CallState::Live,
975
+ "one silent read inside the drop grace is still the same call"
976
+ );
977
+ }
978
+
979
+ #[test]
980
+ fn a_freshly_opened_window_waits_out_the_join_grace() {
981
+ // Joining muted, and the seconds before Zoom opens its input, both read
982
+ // as Some(false). Treating the first one as final is a missed meeting.
983
+ let now = Instant::now();
984
+ let joining = CallEvidence {
985
+ front_since: Some(ago(now, 4)),
442
986
  ..Default::default()
443
987
  };
988
+ assert_eq!(joining.classify(now, Some(false), true), CallState::Pending);
444
989
 
445
- state.refresh_suppression("zoom", now_ts);
446
- state.session_notified.remove("zoom");
990
+ let parked = CallEvidence {
991
+ front_since: Some(ago(now, 60)),
992
+ ..Default::default()
993
+ };
994
+ assert_eq!(
995
+ parked.classify(now, Some(false), true),
996
+ CallState::Idle,
997
+ "past the grace window a silent window is Zoom parked open"
998
+ );
999
+ }
447
1000
 
448
- assert!(state.is_suppressed("zoom", now_ts + COOLDOWN_SECS - 1));
449
- assert!(!state.is_suppressed("zoom", now_ts + COOLDOWN_SECS));
450
- assert_eq!(state.dwell_platform, None);
451
- assert_eq!(state.dwell_since, None);
1001
+ #[test]
1002
+ fn an_unreadable_input_state_falls_back_to_foreground_dwell() {
1003
+ // `kAudioProcessPropertyIsRunningInput` is macOS 14+. Requiring a
1004
+ // stream would leave every older machine detecting nothing at all.
1005
+ let now = Instant::now();
1006
+ let dwelled = CallEvidence {
1007
+ front_since: Some(ago(now, 10)),
1008
+ ..Default::default()
1009
+ };
1010
+ assert_eq!(dwelled.classify(now, None, true), CallState::Live);
1011
+
1012
+ let fresh = CallEvidence {
1013
+ front_since: Some(ago(now, 3)),
1014
+ ..Default::default()
1015
+ };
1016
+ assert_eq!(fresh.classify(now, None, true), CallState::Pending);
1017
+ assert_eq!(dwelled.classify(now, None, false), CallState::Idle);
452
1018
  }
453
1019
 
454
1020
  #[test]
455
- fn dismissal_suppression_remains_platform_scoped() {
456
- let now_ts = 1_000;
1021
+ fn a_finished_call_releases_the_suppression_for_the_next_one() {
1022
+ let now = Instant::now();
1023
+ let mut evidence = CallEvidence::default();
1024
+ evidence.observe(ago(now, 90), Some(true), true);
1025
+ evidence.observe(ago(now, 60), Some(false), true);
1026
+ evidence.observe(now, Some(false), true);
1027
+
1028
+ assert!(
1029
+ evidence.take_call_ended(),
1030
+ "no stream for 30s ends the call even with the window still front"
1031
+ );
1032
+ assert!(
1033
+ !evidence.take_call_ended(),
1034
+ "the end of a call fires once, not on every later tick"
1035
+ );
1036
+
457
1037
  let mut state = AdhocMeetingsWatcherInner::default();
1038
+ state.note_prompted("zoom", 1_000);
1039
+ assert!(state.is_suppressed("zoom", 1_001));
1040
+ state.end_call_session("zoom");
1041
+ assert!(
1042
+ !state.is_suppressed("zoom", 1_001),
1043
+ "back-to-back calls each get their own prompt"
1044
+ );
1045
+ }
458
1046
 
459
- state.refresh_suppression("zoom", now_ts);
1047
+ #[test]
1048
+ fn an_unreadable_read_does_not_end_a_live_call() {
1049
+ // CoreAudio returning `None` mid-call is a read failure, not silence.
1050
+ // Ending the call here releases the per-call suppression, so the same
1051
+ // call gets prompted a second time once readable samples resume.
1052
+ let now = Instant::now();
1053
+ let mut evidence = CallEvidence::default();
1054
+ evidence.observe(ago(now, 60), Some(true), false);
1055
+ for secs in (1..=50).rev() {
1056
+ evidence.observe(ago(now, secs), None, false);
1057
+ assert!(
1058
+ !evidence.take_call_ended(),
1059
+ "an unreadable read must never end a call"
1060
+ );
1061
+ }
1062
+ evidence.observe(now, Some(true), false);
460
1063
 
461
- assert!(state.is_suppressed("zoom", now_ts));
462
- assert!(!state.is_suppressed("teams", now_ts));
1064
+ assert!(
1065
+ !evidence.take_call_ended(),
1066
+ "a stream that was never confirmed gone is still the same call"
1067
+ );
1068
+ assert_eq!(
1069
+ evidence.mic_since,
1070
+ Some(ago(now, 60)),
1071
+ "the original dwell survives the unreadable stretch"
1072
+ );
1073
+ assert_eq!(evidence.classify(now, Some(true), false), CallState::Live);
463
1074
  }
464
- }
465
1075
 
466
- fn reset_dwell(app: &AppHandle) {
467
- if let Some(state) = app.try_state::<AdhocMeetingsWatcherState>() {
468
- if let Ok(mut g) = state.inner.lock() {
469
- g.dwell_platform = None;
470
- g.dwell_since = None;
471
- }
1076
+ #[test]
1077
+ fn an_unreadable_stretch_hands_back_to_the_foreground_fallback() {
1078
+ // Unreadable cannot vouch for a live stream forever, or a machine whose
1079
+ // CoreAudio stopped answering would hold a finished call open. Past the
1080
+ // grace the mic abstains and foreground dwell decides — but it still
1081
+ // reports no call end, because nothing confirmed one.
1082
+ let now = Instant::now();
1083
+ let mut evidence = CallEvidence::default();
1084
+ evidence.observe(ago(now, 300), Some(true), true);
1085
+ evidence.observe(ago(now, 200), None, true);
1086
+ evidence.observe(now, None, true);
1087
+
1088
+ assert!(!evidence.mic_live(now, None));
1089
+ assert!(
1090
+ !evidence.take_call_ended(),
1091
+ "a state we could not read is not a confirmed call end"
1092
+ );
1093
+ assert_eq!(
1094
+ evidence.classify(now, None, true),
1095
+ CallState::Live,
1096
+ "foreground dwell is the only evidence left"
1097
+ );
1098
+ assert_eq!(evidence.classify(now, None, false), CallState::Idle);
472
1099
  }
473
- }
474
1100
 
475
- /// When the frontmost VC platform changes (or clears), drop session_notified
476
- /// for platforms that are no longer front. Keep `cooldown_until` so leaving
477
- /// and re-focusing Zoom within the cooldown does not spam another popup.
478
- fn clear_session_if_left(app: &AppHandle, current: Option<&str>) {
479
- if let Some(state) = app.try_state::<AdhocMeetingsWatcherState>() {
480
- if let Ok(mut g) = state.inner.lock() {
481
- g.session_notified
482
- .retain(|platform, _| current == Some(platform.as_str()));
483
- }
1101
+ #[test]
1102
+ fn a_stream_returning_inside_the_end_threshold_is_the_same_call() {
1103
+ // `CALL_END` is what separates two calls. A stream returning inside it
1104
+ // is one call blipping, so it must not report a boundary — that would
1105
+ // release the suppression and prompt a second time for one call.
1106
+ let now = Instant::now();
1107
+ let mut evidence = CallEvidence::default();
1108
+ evidence.observe(ago(now, 40), Some(true), false);
1109
+ evidence.observe(ago(now, 20), Some(false), false);
1110
+ evidence.observe(ago(now, 12), Some(false), false);
1111
+ evidence.observe(ago(now, 10), Some(true), false);
1112
+
1113
+ assert!(
1114
+ !evidence.take_call_ended(),
1115
+ "a 10s gap is a blip, not the end of the call"
1116
+ );
1117
+ assert!(
1118
+ evidence.mic_is_authoritative(now),
1119
+ "a readable stream still decides when this call ends"
1120
+ );
484
1121
  }
485
- }
486
1122
 
487
- async fn create_adhoc_meeting(
488
- app: &AppHandle,
489
- client: &reqwest::Client,
490
- platform: &str,
491
- ) -> Result<MeetingItem, String> {
492
- let session = app
493
- .try_state::<MeetingsWatcherState>()
494
- .map(|s| s.session_snapshot())
495
- .unwrap_or_default();
496
- let Some(server_url) = session.server_url.as_deref() else {
497
- return Err("no server_url for create-meeting".to_string());
498
- };
1123
+ #[test]
1124
+ fn a_backgrounded_call_keeps_its_suppression_until_the_confirmed_end() {
1125
+ // The window must not pre-empt the stream. `mic_live` goes false after
1126
+ // 8s of silence, but the call is not over until 30s — releasing
1127
+ // suppression in between lets one call prompt twice.
1128
+ let now = Instant::now();
1129
+ let mut evidence = CallEvidence::default();
1130
+ evidence.observe(ago(now, 60), Some(true), false);
1131
+ evidence.observe(ago(now, 12), Some(false), false);
1132
+
1133
+ assert!(!evidence.mic_live(now, Some(false)));
1134
+ assert!(
1135
+ evidence.mic_is_authoritative(now),
1136
+ "a readable silence run is the stream's business, not the window's"
1137
+ );
1138
+ assert!(!evidence.take_call_ended());
1139
+ }
499
1140
 
500
- // The native watcher knows which conferencing app is active, but not the
501
- // calendar event title. Resolve the nearest joinable event first so a
502
- // calendar-backed meeting keeps its title, URL, and scheduled span. A
503
- // disconnected calendar only loses that enrichment and still gets an
504
- // adhoc meeting below.
505
- match find_calendar_meeting(app, client, server_url, &session, platform).await {
506
- Ok(Some(meeting)) => {
507
- dlog!(
508
- "[clips-tray] adhoc matched calendar meeting {} for {}",
509
- meeting.id,
510
- platform
511
- );
512
- return Ok(meeting);
513
- }
514
- Ok(None) => {}
515
- Err(error) => dlog!(
1141
+ #[test]
1142
+ fn an_unreadable_background_call_gives_up_only_the_clockless_suppression() {
1143
+ // Past the unreadable grace we genuinely do not know. The prompt
1144
+ // cooldown expires on its own, so it can stay; `session_notified`
1145
+ // cannot, so it must go or the platform is stranded for good.
1146
+ let now = Instant::now();
1147
+ let mut evidence = CallEvidence::default();
1148
+ evidence.observe(ago(now, 300), Some(true), false);
1149
+ evidence.observe(ago(now, 200), None, false);
1150
+ evidence.observe(now, None, false);
1151
+
1152
+ assert!(evidence.mic_ever_spoke());
1153
+ assert!(
1154
+ !evidence.mic_is_authoritative(now),
1155
+ "an unreadable run past its grace is no longer an authority"
1156
+ );
1157
+ assert!(!evidence.take_call_ended());
1158
+
1159
+ let mut state = AdhocMeetingsWatcherInner::default();
1160
+ state.note_prompted("zoom", 1_000);
1161
+ state.session_notified.remove("zoom");
1162
+ assert!(
1163
+ state.is_suppressed("zoom", 1_000 + COOLDOWN_SECS - 1),
1164
+ "the cooldown still guards against a duplicate prompt"
1165
+ );
1166
+ assert!(
1167
+ !state.is_suppressed("zoom", 1_000 + COOLDOWN_SECS),
1168
+ "and it expires, so the platform is never stranded"
1169
+ );
1170
+ }
1171
+
1172
+ #[test]
1173
+ fn a_platform_with_no_stream_evidence_is_the_only_one_the_window_ends() {
1174
+ let now = Instant::now();
1175
+ let mut fallback = CallEvidence::default();
1176
+ fallback.observe(ago(now, 30), None, true);
1177
+ fallback.observe(now, None, true);
1178
+ assert!(
1179
+ !fallback.mic_ever_spoke(),
1180
+ "macOS 13 never records a stream, so the window is all there is"
1181
+ );
1182
+ assert!(!fallback.mic_is_authoritative(now));
1183
+ }
1184
+
1185
+ #[test]
1186
+ fn a_second_muted_call_in_the_same_window_gets_a_fresh_join_grace() {
1187
+ // Zoom never left the foreground, so without re-arming the grace the
1188
+ // next call reads as a window parked open and stays Idle forever.
1189
+ let now = Instant::now();
1190
+ let mut evidence = CallEvidence::default();
1191
+ evidence.observe(ago(now, 600), Some(true), true);
1192
+ evidence.observe(ago(now, 100), Some(false), true);
1193
+ evidence.observe(ago(now, 10), Some(false), true);
1194
+ assert!(evidence.take_call_ended());
1195
+
1196
+ assert_eq!(
1197
+ evidence.classify(now, Some(false), true),
1198
+ CallState::Pending,
1199
+ "joining the next call muted still gets its join grace"
1200
+ );
1201
+ }
1202
+
1203
+ #[test]
1204
+ fn losing_the_window_never_releases_the_prompt_cooldown() {
1205
+ // On the fallback path, switching to Slack mid-call is indistinguishable
1206
+ // from the call ending. Releasing the cooldown there would re-prompt
1207
+ // the same still-running call the moment the user came back, so only a
1208
+ // confirmed call end may clear it.
1209
+ let now_ts = 1_000;
1210
+ let mut state = AdhocMeetingsWatcherInner::default();
1211
+ state.note_prompted("zoom", now_ts);
1212
+
1213
+ state.session_notified.remove("zoom");
1214
+ assert!(
1215
+ state.is_suppressed("zoom", now_ts + 1),
1216
+ "the same call must not prompt again on returning to the window"
1217
+ );
1218
+ assert!(
1219
+ !state.is_suppressed("zoom", now_ts + COOLDOWN_SECS),
1220
+ "and the cooldown still expires, so the platform is never stranded"
1221
+ );
1222
+
1223
+ state.end_call_session("zoom");
1224
+ assert!(
1225
+ !state.is_suppressed("zoom", now_ts + 1),
1226
+ "a confirmed call end is what frees the next call"
1227
+ );
1228
+ }
1229
+
1230
+ #[test]
1231
+ fn abandoning_evidence_converts_suppression_into_a_bounded_one() {
1232
+ // Transcription starts, so the watcher stops sampling. The per-call flag
1233
+ // cannot stay (nothing would be left to release it) and cannot simply
1234
+ // go (stopping notes mid-call would re-prompt the call still running),
1235
+ // so it becomes the cooldown, which expires on its own.
1236
+ let now_ts = 1_000;
1237
+ let mut state = AdhocMeetingsWatcherInner::default();
1238
+ state.note_prompted("zoom", now_ts);
1239
+ state.note_dismissed("teams", now_ts);
1240
+ state
1241
+ .evidence
1242
+ .insert("zoom".to_string(), CallEvidence::default());
1243
+
1244
+ state.clear_tracking(now_ts + 30);
1245
+
1246
+ assert!(state.evidence.is_empty());
1247
+ assert!(
1248
+ state.session_notified.is_empty(),
1249
+ "the clockless flag never survives the evidence that releases it"
1250
+ );
1251
+ assert!(
1252
+ state.is_suppressed("zoom", now_ts + 31),
1253
+ "a call that outlives transcription is not prompted for twice"
1254
+ );
1255
+ assert!(
1256
+ !state.is_suppressed("zoom", now_ts + 30 + COOLDOWN_SECS),
1257
+ "and the suppression is bounded, so it can never strand"
1258
+ );
1259
+ assert!(
1260
+ state.is_suppressed("teams", now_ts + 31),
1261
+ "a dismissal has its own clock and is not ours to discard"
1262
+ );
1263
+ }
1264
+
1265
+ #[test]
1266
+ fn a_failed_create_backs_off_instead_of_retrying_every_tick() {
1267
+ // The evidence still says a call is underway, so the retry lands on the
1268
+ // next two-second tick and every tick after it — and a create that
1269
+ // committed but lost its response leaves a row per attempt.
1270
+ let now_ts = 1_000;
1271
+ let mut state = AdhocMeetingsWatcherInner::default();
1272
+ state.note_prompted("zoom", now_ts);
1273
+
1274
+ state.note_create_failed("zoom", now_ts, false);
1275
+
1276
+ assert!(
1277
+ state.is_suppressed("zoom", now_ts + CREATE_RETRY_BACKOFF_SECS - 1),
1278
+ "the same call must not resubmit on the next tick"
1279
+ );
1280
+ assert!(
1281
+ !state.is_suppressed("zoom", now_ts + CREATE_RETRY_BACKOFF_SECS),
1282
+ "but the call is retried rather than abandoned"
1283
+ );
1284
+ assert!(
1285
+ CREATE_RETRY_BACKOFF_SECS < COOLDOWN_SECS,
1286
+ "a failed create retries sooner than a successful prompt repeats"
1287
+ );
1288
+ }
1289
+
1290
+ #[test]
1291
+ fn a_create_that_could_not_have_committed_does_not_force_a_reconcile() {
1292
+ let now_ts = 1_000;
1293
+ let mut state = AdhocMeetingsWatcherInner::default();
1294
+
1295
+ state.note_create_failed("zoom", now_ts, false);
1296
+
1297
+ assert_eq!(
1298
+ state.create_doubt_since("zoom"),
1299
+ None,
1300
+ "a rejected request wrote nothing, so there is no row to look for"
1301
+ );
1302
+ }
1303
+
1304
+ #[test]
1305
+ fn an_unreadable_create_outcome_makes_the_next_attempt_reconcile() {
1306
+ let now_ts = 1_000;
1307
+ let mut state = AdhocMeetingsWatcherInner::default();
1308
+
1309
+ state.note_create_failed("zoom", now_ts, true);
1310
+
1311
+ assert_eq!(
1312
+ state.create_doubt_since("zoom"),
1313
+ Some(now_ts),
1314
+ "the attempt may have committed, so the retry must look before inserting"
1315
+ );
1316
+ assert_eq!(
1317
+ state.create_doubt_since("teams"),
1318
+ None,
1319
+ "doubt is per platform"
1320
+ );
1321
+ }
1322
+
1323
+ #[test]
1324
+ fn a_run_of_unreadable_attempts_reconciles_against_the_first_one() {
1325
+ // Any attempt in the run could be the one that landed, so the window has
1326
+ // to reach back to the earliest — a later timestamp would look right past
1327
+ // the row the first attempt created.
1328
+ let now_ts = 1_000;
1329
+ let mut state = AdhocMeetingsWatcherInner::default();
1330
+
1331
+ state.note_create_failed("zoom", now_ts, true);
1332
+ state.note_create_failed("zoom", now_ts + CREATE_RETRY_BACKOFF_SECS, true);
1333
+
1334
+ assert_eq!(state.create_doubt_since("zoom"), Some(now_ts));
1335
+ }
1336
+
1337
+ #[test]
1338
+ fn a_resolved_create_stops_reconciling() {
1339
+ let now_ts = 1_000;
1340
+ let mut state = AdhocMeetingsWatcherInner::default();
1341
+ state.note_create_failed("zoom", now_ts, true);
1342
+
1343
+ state.note_create_resolved("zoom");
1344
+
1345
+ assert_eq!(state.create_doubt_since("zoom"), None);
1346
+ }
1347
+
1348
+ #[test]
1349
+ fn a_finished_call_drops_the_doubt_from_the_call_that_ended() {
1350
+ // Otherwise the next call reconciles against the previous call's row and
1351
+ // adopts it, attaching this call's notes to the wrong meeting.
1352
+ let now_ts = 1_000;
1353
+ let mut state = AdhocMeetingsWatcherInner::default();
1354
+ state.note_create_failed("zoom", now_ts, true);
1355
+
1356
+ state.end_call_session("zoom");
1357
+
1358
+ assert_eq!(state.create_doubt_since("zoom"), None);
1359
+ }
1360
+
1361
+ #[test]
1362
+ fn an_unresolved_create_doubt_expires_into_a_cooldown() {
1363
+ // The doubt is pinned to one call. On the fallback path the watcher may
1364
+ // never see that call end, so the marker needs a clock of its own — but
1365
+ // expiring it on its own would let the next tick skip the reconcile and
1366
+ // bare-insert a row that may already exist.
1367
+ let now_ts = 1_000;
1368
+ let mut state = AdhocMeetingsWatcherInner::default();
1369
+ state.note_create_failed("zoom", now_ts, true);
1370
+
1371
+ state.retain_live_cooldowns(now_ts + CREATE_DOUBT_TTL_SECS - 1);
1372
+ assert_eq!(state.create_doubt_since("zoom"), Some(now_ts));
1373
+
1374
+ let expiry = now_ts + CREATE_DOUBT_TTL_SECS;
1375
+ state.retain_live_cooldowns(expiry);
1376
+ assert_eq!(state.create_doubt_since("zoom"), None);
1377
+ assert!(
1378
+ state.is_suppressed("zoom", expiry),
1379
+ "an unsettled write must not fall through to a fresh insert"
1380
+ );
1381
+ assert!(
1382
+ state.is_suppressed("zoom", expiry + COOLDOWN_SECS - 1),
1383
+ "the pause runs a full cooldown"
1384
+ );
1385
+ assert!(
1386
+ !state.is_suppressed("zoom", expiry + COOLDOWN_SECS),
1387
+ "and is bounded, so the platform is never suppressed forever"
1388
+ );
1389
+ }
1390
+
1391
+ #[test]
1392
+ fn abandoning_tracking_drops_the_doubt_too() {
1393
+ let now_ts = 1_000;
1394
+ let mut state = AdhocMeetingsWatcherInner::default();
1395
+ state.note_create_failed("zoom", now_ts, true);
1396
+
1397
+ state.clear_tracking(now_ts);
1398
+
1399
+ assert_eq!(
1400
+ state.create_doubt_since("zoom"),
1401
+ None,
1402
+ "there is no tracked call left to tie a found row to"
1403
+ );
1404
+ }
1405
+
1406
+ #[test]
1407
+ fn a_deferred_platform_is_suppressed_only_once_the_winner_has_a_row() {
1408
+ // Both platforms confirmed on one poll. Until the selected platform
1409
+ // actually produces a meeting, the loser must stay eligible — if the
1410
+ // selected flow errors or the calendar guard skips it, the loser is the
1411
+ // only live call left and it is the one the user wants.
1412
+ let now_ts = 1_000;
1413
+ let mut state = AdhocMeetingsWatcherInner::default();
1414
+ state.note_prompted("zoom", now_ts);
1415
+
1416
+ assert!(
1417
+ !state.is_suppressed("teams", now_ts),
1418
+ "losing the tie-break is not grounds for suppression on its own"
1419
+ );
1420
+
1421
+ state.defer_secondary_candidates(&["teams"]);
1422
+
1423
+ assert!(
1424
+ state.is_suppressed("teams", now_ts),
1425
+ "once the winner has a row, one poll has prompted for one call"
1426
+ );
1427
+ }
1428
+
1429
+ #[test]
1430
+ fn a_deferred_platform_survives_the_winners_failure() {
1431
+ let now_ts = 1_000;
1432
+ let mut state = AdhocMeetingsWatcherInner::default();
1433
+ state.note_prompted("zoom", now_ts);
1434
+
1435
+ // The selected platform's create failed, so the deferral never happens.
1436
+ state.note_create_failed("zoom", now_ts, true);
1437
+
1438
+ assert!(
1439
+ !state.is_suppressed("teams", now_ts),
1440
+ "the other live call must still be promptable on the next tick"
1441
+ );
1442
+ }
1443
+
1444
+ #[test]
1445
+ fn a_calendar_guarded_platform_yields_the_next_poll_to_the_other_call() {
1446
+ // The guard records the skip exactly like a prompt, cooldown included,
1447
+ // so the guarded platform stops being first in line. A concurrent call
1448
+ // on the other platform is selected one tick later, not starved.
1449
+ let now_ts = 1_000;
1450
+ let mut state = AdhocMeetingsWatcherInner::default();
1451
+
1452
+ state.note_prompted("zoom", now_ts);
1453
+
1454
+ let next_tick = now_ts + POLL_SECS as i64;
1455
+ assert!(
1456
+ state.is_suppressed("zoom", next_tick),
1457
+ "the guarded platform is no longer eligible to be picked first"
1458
+ );
1459
+ assert!(
1460
+ !state.is_suppressed("teams", next_tick),
1461
+ "so the other live call becomes this poll's candidate"
1462
+ );
1463
+ }
1464
+
1465
+ #[test]
1466
+ fn a_deferred_platform_is_released_by_its_own_call_end() {
1467
+ let now_ts = 1_000;
1468
+ let mut state = AdhocMeetingsWatcherInner::default();
1469
+ state.defer_secondary_candidates(&["teams"]);
1470
+ assert!(state.is_suppressed("teams", now_ts));
1471
+
1472
+ state.end_call_session("teams");
1473
+
1474
+ assert!(
1475
+ !state.is_suppressed("teams", now_ts),
1476
+ "a deferral carries no cooldown, so the next call prompts immediately"
1477
+ );
1478
+ }
1479
+
1480
+ fn adhoc_row(id: &str, platform: &str, start: &str) -> serde_json::Value {
1481
+ serde_json::json!({
1482
+ "id": id,
1483
+ "platform": platform,
1484
+ "source": "adhoc",
1485
+ "scheduledStart": start,
1486
+ })
1487
+ }
1488
+
1489
+ fn ts(rfc3339: &str) -> i64 {
1490
+ chrono::DateTime::parse_from_rfc3339(rfc3339)
1491
+ .unwrap()
1492
+ .timestamp()
1493
+ }
1494
+
1495
+ /// The attempt that went unanswered, and the retry reading back a minute on.
1496
+ const ATTEMPT: &str = "2026-08-19T10:00:00Z";
1497
+ const RETRY: &str = "2026-08-19T10:01:00Z";
1498
+
1499
+ #[test]
1500
+ fn reconcile_adopts_the_row_a_lost_response_left_behind() {
1501
+ let meetings = parse_meetings(&serde_json::json!({
1502
+ "meetings": [adhoc_row("zoom-row", "zoom", "2026-08-19T10:00:05Z")],
1503
+ }));
1504
+
1505
+ let found = pick_recent_adhoc_meeting(&meetings, "zoom", ts(ATTEMPT), ts(RETRY));
1506
+
1507
+ assert_eq!(found.map(|m| m.id), Some("zoom-row".to_string()));
1508
+ }
1509
+
1510
+ #[test]
1511
+ fn reconcile_ignores_a_previous_calls_row() {
1512
+ // The row predates the attempt, so it belongs to an earlier call.
1513
+ // Adopting it would file this call's notes under that meeting.
1514
+ let meetings = parse_meetings(&serde_json::json!({
1515
+ "meetings": [adhoc_row("old-row", "zoom", "2026-08-19T09:30:00Z")],
1516
+ }));
1517
+
1518
+ assert!(pick_recent_adhoc_meeting(&meetings, "zoom", ts(ATTEMPT), ts(RETRY)).is_none());
1519
+ }
1520
+
1521
+ #[test]
1522
+ fn reconcile_ignores_a_scheduled_future_row() {
1523
+ // The agenda view runs from the lookback "onward" with no upper bound,
1524
+ // so a future ad-hoc row for this platform comes back too — and on
1525
+ // recency alone it would beat the row this call actually created.
1526
+ let meetings = parse_meetings(&serde_json::json!({
1527
+ "meetings": [
1528
+ adhoc_row("this-call", "zoom", "2026-08-19T10:00:05Z"),
1529
+ adhoc_row("scheduled-later", "zoom", "2026-08-19T15:00:00Z"),
1530
+ ],
1531
+ }));
1532
+
1533
+ assert_eq!(
1534
+ pick_recent_adhoc_meeting(&meetings, "zoom", ts(ATTEMPT), ts(RETRY)).map(|m| m.id),
1535
+ Some("this-call".to_string()),
1536
+ "a meeting that has not happened yet is not the row we just wrote"
1537
+ );
1538
+ }
1539
+
1540
+ #[test]
1541
+ fn reconcile_stays_on_its_own_platform_and_source() {
1542
+ let meetings = parse_meetings(&serde_json::json!({
1543
+ "meetings": [
1544
+ adhoc_row("teams-row", "teams", "2026-08-19T10:00:05Z"),
1545
+ {
1546
+ "id": "calendar-row",
1547
+ "platform": "zoom",
1548
+ "source": "calendar",
1549
+ "scheduledStart": "2026-08-19T10:00:05Z",
1550
+ },
1551
+ ],
1552
+ }));
1553
+
1554
+ assert!(
1555
+ pick_recent_adhoc_meeting(&meetings, "zoom", ts(ATTEMPT), ts(RETRY)).is_none(),
1556
+ "a Teams row and a calendar row are both the wrong meeting to adopt"
1557
+ );
1558
+ }
1559
+
1560
+ #[test]
1561
+ fn reconcile_takes_the_newest_row_when_attempts_already_duplicated() {
1562
+ let meetings = parse_meetings(&serde_json::json!({
1563
+ "meetings": [
1564
+ adhoc_row("first", "zoom", "2026-08-19T10:00:05Z"),
1565
+ adhoc_row("second", "zoom", "2026-08-19T10:00:50Z"),
1566
+ ],
1567
+ }));
1568
+
1569
+ assert_eq!(
1570
+ pick_recent_adhoc_meeting(&meetings, "zoom", ts(ATTEMPT), ts(RETRY)).map(|m| m.id),
1571
+ Some("second".to_string())
1572
+ );
1573
+ }
1574
+
1575
+ #[test]
1576
+ fn reconcile_tolerates_clock_skew_on_both_window_edges() {
1577
+ // `scheduledStart` is stamped here but read back through the server.
1578
+ let just_before = parse_meetings(&serde_json::json!({
1579
+ "meetings": [adhoc_row("zoom-row", "zoom", "2026-08-19T09:59:55Z")],
1580
+ }));
1581
+ assert_eq!(
1582
+ pick_recent_adhoc_meeting(&just_before, "zoom", ts(ATTEMPT), ts(RETRY)).map(|m| m.id),
1583
+ Some("zoom-row".to_string()),
1584
+ "a few seconds of skew must not hide the row we just created"
1585
+ );
1586
+
1587
+ let just_after = parse_meetings(&serde_json::json!({
1588
+ "meetings": [adhoc_row("zoom-row", "zoom", "2026-08-19T10:01:05Z")],
1589
+ }));
1590
+ assert_eq!(
1591
+ pick_recent_adhoc_meeting(&just_after, "zoom", ts(ATTEMPT), ts(RETRY)).map(|m| m.id),
1592
+ Some("zoom-row".to_string()),
1593
+ "nor may it hide a row stamped a moment ahead of our clock"
1594
+ );
1595
+
1596
+ assert!(
1597
+ RECONCILE_SKEW_SECS < CALL_END.as_secs() as i64,
1598
+ "the skew allowance must not reach into a previous call"
1599
+ );
1600
+ }
1601
+
1602
+ #[test]
1603
+ fn an_unparseable_start_is_not_treated_as_a_match() {
1604
+ let meetings = parse_meetings(&serde_json::json!({
1605
+ "meetings": [
1606
+ { "id": "no-start", "platform": "zoom", "source": "adhoc" },
1607
+ adhoc_row("bad-start", "zoom", "not-a-timestamp"),
1608
+ ],
1609
+ }));
1610
+
1611
+ assert!(
1612
+ pick_recent_adhoc_meeting(&meetings, "zoom", 0, ts(RETRY)).is_none(),
1613
+ "a row whose start cannot be read is not a row we can place in the window"
1614
+ );
1615
+ }
1616
+
1617
+ #[test]
1618
+ fn a_body_that_is_not_a_meetings_list_is_unreadable_not_empty() {
1619
+ // The defect this guards: `parse_meetings` flattens an unknown envelope
1620
+ // to an empty vector, so a 200 error payload would read as a checked
1621
+ // "no such meeting" and the retry would insert the duplicate.
1622
+ use crate::meetings_watcher::try_parse_meetings;
1623
+
1624
+ assert!(
1625
+ try_parse_meetings(&serde_json::json!({ "error": "boom" })).is_none(),
1626
+ "an error payload says nothing about whether a row exists"
1627
+ );
1628
+ assert!(
1629
+ try_parse_meetings(&serde_json::json!({ "rows": [] })).is_none(),
1630
+ "a changed envelope is not an empty list"
1631
+ );
1632
+ assert_eq!(
1633
+ try_parse_meetings(&serde_json::json!({ "meetings": [] })).map(|rows| rows.len()),
1634
+ Some(0),
1635
+ "an actual empty list is the one answer that permits a create"
1636
+ );
1637
+ assert!(
1638
+ parse_meetings(&serde_json::json!({ "error": "boom" })).is_empty(),
1639
+ "read-only callers keep their lenient behavior"
1640
+ );
1641
+ }
1642
+
1643
+ #[test]
1644
+ fn a_short_page_covers_the_reconcile_window() {
1645
+ let meetings = parse_meetings(&serde_json::json!({
1646
+ "meetings": [adhoc_row("only", "zoom", "2026-08-19T10:00:05Z")],
1647
+ }));
1648
+
1649
+ assert!(
1650
+ reconcile_window_was_covered(&meetings, ts(RETRY)),
1651
+ "a page shorter than the limit is the whole result"
1652
+ );
1653
+ }
1654
+
1655
+ #[test]
1656
+ fn a_full_page_ending_early_does_not_cover_the_window() {
1657
+ // Ascending agenda order means a truncated page proves only what it
1658
+ // reached. Concluding "no row" from it would insert the duplicate.
1659
+ let rows: Vec<serde_json::Value> = (0..RECONCILE_PAGE_LIMIT)
1660
+ .map(|i| adhoc_row(&format!("row-{i}"), "teams", "2026-08-19T09:55:00Z"))
1661
+ .collect();
1662
+ let meetings = parse_meetings(&serde_json::json!({ "meetings": rows }));
1663
+
1664
+ assert_eq!(meetings.len(), RECONCILE_PAGE_LIMIT);
1665
+ assert!(
1666
+ !reconcile_window_was_covered(&meetings, ts(RETRY)),
1667
+ "the row we want could sit past the cut"
1668
+ );
1669
+ }
1670
+
1671
+ #[test]
1672
+ fn a_full_page_reaching_past_the_window_still_covers_it() {
1673
+ let mut rows: Vec<serde_json::Value> = (0..RECONCILE_PAGE_LIMIT - 1)
1674
+ .map(|i| adhoc_row(&format!("row-{i}"), "teams", "2026-08-19T09:55:00Z"))
1675
+ .collect();
1676
+ rows.push(adhoc_row("future", "teams", "2026-08-19T18:00:00Z"));
1677
+ let meetings = parse_meetings(&serde_json::json!({ "meetings": rows }));
1678
+
1679
+ assert!(
1680
+ reconcile_window_was_covered(&meetings, ts(RETRY)),
1681
+ "the page ran past the window, so nothing inside it was cut off"
1682
+ );
1683
+ }
1684
+
1685
+ #[test]
1686
+ fn an_unreadable_stream_hands_the_session_over_exactly_once() {
1687
+ // The window may never change again, so the moment the stream stops
1688
+ // being readable is the only boundary left to release the flag on.
1689
+ let now = Instant::now();
1690
+ let mut evidence = CallEvidence::default();
1691
+ evidence.observe(ago(now, 300), Some(true), true);
1692
+ assert!(!evidence.take_mic_abstained());
1693
+
1694
+ evidence.observe(ago(now, 200), None, true);
1695
+ assert!(
1696
+ !evidence.take_mic_abstained(),
1697
+ "inside the grace the stream still speaks for the call"
1698
+ );
1699
+
1700
+ evidence.observe(now, None, true);
1701
+ assert!(
1702
+ evidence.take_mic_abstained(),
1703
+ "past the grace the session is handed to the fallback"
1704
+ );
1705
+ assert!(
1706
+ !evidence.take_mic_abstained(),
1707
+ "and handed over once, not on every later tick"
1708
+ );
1709
+ }
1710
+
1711
+ #[test]
1712
+ fn a_confirmed_call_end_is_not_an_abstention() {
1713
+ let now = Instant::now();
1714
+ let mut evidence = CallEvidence::default();
1715
+ evidence.observe(ago(now, 90), Some(true), true);
1716
+ evidence.observe(ago(now, 60), Some(false), true);
1717
+ evidence.observe(now, Some(false), true);
1718
+
1719
+ assert!(evidence.take_call_ended());
1720
+ assert!(
1721
+ !evidence.take_mic_abstained(),
1722
+ "the stream exercised its authority rather than losing it"
1723
+ );
1724
+ }
1725
+
1726
+ #[test]
1727
+ fn unreadable_time_does_not_count_as_confirmed_silence() {
1728
+ // The unreadable stretch may have been full of audio. Counting it would
1729
+ // let the first `Some(false)` afterwards satisfy CALL_END on its own and
1730
+ // end a call that never stopped.
1731
+ let now = Instant::now();
1732
+ let mut evidence = CallEvidence::default();
1733
+ evidence.observe(ago(now, 120), Some(true), false);
1734
+ evidence.observe(ago(now, 118), Some(false), false);
1735
+ evidence.observe(ago(now, 100), None, false);
1736
+ evidence.observe(ago(now, 10), None, false);
1737
+ evidence.observe(now, Some(false), false);
1738
+
1739
+ assert!(
1740
+ !evidence.take_call_ended(),
1741
+ "the silence clock restarts after time we could not read"
1742
+ );
1743
+ }
1744
+
1745
+ #[test]
1746
+ fn ask_mode_prompts_without_auto_starting() {
1747
+ // The b00c38db4 regression: Ask is the shipped default, so if this
1748
+ // stops surfacing the overlay, ad-hoc detection is dead for everyone
1749
+ // who never opened Settings.
1750
+ let plan = adhoc_notification_plan(&config_with(MeetingTranscriptionMode::Ask, false));
1751
+ assert!(plan.show_widget);
1752
+ assert!(!plan.auto_start);
1753
+ }
1754
+
1755
+ #[test]
1756
+ fn auto_mode_prompts_and_starts() {
1757
+ let plan = adhoc_notification_plan(&config_with(MeetingTranscriptionMode::Auto, false));
1758
+ assert!(plan.show_widget);
1759
+ assert!(plan.auto_start);
1760
+ }
1761
+
1762
+ #[test]
1763
+ fn manual_mode_stays_silent_only_when_the_widget_is_disabled() {
1764
+ let hidden = adhoc_notification_plan(&config_with(MeetingTranscriptionMode::Manual, false));
1765
+ assert!(!hidden.show_widget);
1766
+ assert!(!hidden.auto_start);
1767
+
1768
+ let shown = adhoc_notification_plan(&config_with(MeetingTranscriptionMode::Manual, true));
1769
+ assert!(shown.show_widget);
1770
+ assert!(!shown.auto_start);
1771
+ }
1772
+
1773
+ #[test]
1774
+ fn dismissal_suppresses_the_rest_of_the_call() {
1775
+ let now_ts = 1_000;
1776
+ let mut state = AdhocMeetingsWatcherInner::default();
1777
+
1778
+ state.note_dismissed("zoom", now_ts);
1779
+ state.session_notified.remove("zoom");
1780
+
1781
+ assert!(state.is_suppressed("zoom", now_ts + COOLDOWN_SECS - 1));
1782
+ assert!(!state.is_suppressed("zoom", now_ts + COOLDOWN_SECS));
1783
+ }
1784
+
1785
+ #[test]
1786
+ fn dismissal_suppression_remains_platform_scoped() {
1787
+ let now_ts = 1_000;
1788
+ let mut state = AdhocMeetingsWatcherInner::default();
1789
+
1790
+ state.note_dismissed("zoom", now_ts);
1791
+
1792
+ assert!(state.is_suppressed("zoom", now_ts));
1793
+ assert!(!state.is_suppressed("teams", now_ts));
1794
+ }
1795
+ }
1796
+
1797
+ fn reset_evidence(app: &AppHandle) {
1798
+ if let Some(state) = app.try_state::<AdhocMeetingsWatcherState>() {
1799
+ if let Ok(mut g) = state.inner.lock() {
1800
+ g.clear_tracking(chrono::Utc::now().timestamp());
1801
+ }
1802
+ }
1803
+ }
1804
+
1805
+ async fn create_adhoc_meeting(
1806
+ app: &AppHandle,
1807
+ client: &reqwest::Client,
1808
+ platform: &str,
1809
+ reconcile_since: Option<i64>,
1810
+ ) -> Result<MeetingItem, CreateFailure> {
1811
+ let session = app
1812
+ .try_state::<MeetingsWatcherState>()
1813
+ .map(|s| s.session_snapshot())
1814
+ .unwrap_or_default();
1815
+ let Some(server_url) = session.server_url.as_deref() else {
1816
+ return Err(CreateFailure::NotCommitted(
1817
+ "no server_url for create-meeting".to_string(),
1818
+ ));
1819
+ };
1820
+
1821
+ // A previous attempt for this same call may have committed before its
1822
+ // response went missing. `create-meeting` is only idempotent on its
1823
+ // `calendarEventId` path (that one claims the event row atomically); the
1824
+ // ad-hoc path is a bare insert, so nothing server-side collapses a second
1825
+ // attempt into the first. Until it has an idempotency key, the only way not
1826
+ // to leave one row per attempt is to go looking for the first row.
1827
+ //
1828
+ // Before the calendar lookup, not after. A retry whose calendar match has
1829
+ // since become resolvable would otherwise return that calendar meeting, and
1830
+ // the caller would clear the doubt against it — stranding the ad-hoc row the
1831
+ // earlier attempt actually wrote, with nothing left that would ever find it.
1832
+ // An outstanding write is settled first; enrichment is what happens on a
1833
+ // call that has no row yet.
1834
+ //
1835
+ // A lookup that fails is not a lookup that found nothing. Treating an
1836
+ // unreadable reconcile as "no existing row" would insert the duplicate this
1837
+ // whole path exists to avoid, so it abandons the attempt and stays in doubt
1838
+ // for the next tick instead.
1839
+ if let Some(since) = reconcile_since {
1840
+ match find_recent_adhoc_meeting(app, client, server_url, &session, platform, since).await {
1841
+ Ok(Some(existing)) => {
1842
+ dlog!(
1843
+ "[clips-tray] adhoc reconciled to existing meeting {} for {}",
1844
+ existing.id,
1845
+ platform
1846
+ );
1847
+ return Ok(existing);
1848
+ }
1849
+ Ok(None) => {}
1850
+ Err(error) => {
1851
+ return Err(CreateFailure::Ambiguous(format!(
1852
+ "adhoc reconcile unreadable for {platform}, not retrying create: {error}"
1853
+ )));
1854
+ }
1855
+ }
1856
+ }
1857
+
1858
+ // The native watcher knows which conferencing app is active, but not the
1859
+ // calendar event title. Resolve the nearest joinable event first so a
1860
+ // calendar-backed meeting keeps its title, URL, and scheduled span. A
1861
+ // disconnected calendar only loses that enrichment and still gets an
1862
+ // adhoc meeting below.
1863
+ match find_calendar_meeting(app, client, server_url, &session, platform).await {
1864
+ Ok(Some(meeting)) => {
1865
+ dlog!(
1866
+ "[clips-tray] adhoc matched calendar meeting {} for {}",
1867
+ meeting.id,
1868
+ platform
1869
+ );
1870
+ return Ok(meeting);
1871
+ }
1872
+ Ok(None) => {}
1873
+ Err(error) => dlog!(
516
1874
  "[clips-tray] adhoc calendar title lookup skipped for {}: {}",
517
1875
  platform,
518
1876
  error
@@ -545,29 +1903,51 @@ async fn create_adhoc_meeting(
545
1903
  req = req.bearer_auth(token);
546
1904
  }
547
1905
 
548
- let resp = req
549
- .send()
550
- .await
551
- .map_err(|e| format!("create-meeting fetch: {e}"))?;
1906
+ let resp = req.send().await.map_err(|e| {
1907
+ let message = format!("create-meeting fetch: {e}");
1908
+ // A request that never opened a connection cannot have written
1909
+ // anything. Everything else here — a timeout above all — may have been
1910
+ // answered by a handler that had already committed.
1911
+ if e.is_connect() || e.is_builder() {
1912
+ CreateFailure::NotCommitted(message)
1913
+ } else {
1914
+ CreateFailure::Ambiguous(message)
1915
+ }
1916
+ })?;
552
1917
  let status = resp.status();
553
1918
  if status == reqwest::StatusCode::UNAUTHORIZED {
554
1919
  let _ = app.emit("meetings:auth-needed", serde_json::json!({}));
555
- return Err("create-meeting http 401".to_string());
1920
+ return Err(CreateFailure::NotCommitted(
1921
+ "create-meeting http 401".to_string(),
1922
+ ));
556
1923
  }
557
1924
  if !status.is_success() {
558
1925
  let text = resp.text().await.unwrap_or_default();
559
- return Err(format!(
1926
+ let message = format!(
560
1927
  "create-meeting http {} — {}",
561
1928
  status,
562
1929
  text.chars().take(180).collect::<String>()
563
- ));
1930
+ );
1931
+ // A 4xx is the action refusing the request, so no row exists. A 5xx can
1932
+ // be raised after the insert committed — by the app-state write or the
1933
+ // read-back that follows it — so it has to be treated as unresolved.
1934
+ return Err(if status.is_client_error() {
1935
+ CreateFailure::NotCommitted(message)
1936
+ } else {
1937
+ CreateFailure::Ambiguous(message)
1938
+ });
564
1939
  }
565
- let body: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
1940
+ // Past here the server returned success, so a row exists. Anything we then
1941
+ // fail to read about it leaves us holding a committed write we cannot name.
1942
+ let body: serde_json::Value = resp
1943
+ .json()
1944
+ .await
1945
+ .map_err(|e| CreateFailure::Ambiguous(format!("create-meeting response: {e}")))?;
566
1946
  let id = extract_meeting_id(&body).ok_or_else(|| {
567
- format!(
1947
+ CreateFailure::Ambiguous(format!(
568
1948
  "create-meeting response missing meeting id: {}",
569
1949
  body.to_string().chars().take(200).collect::<String>()
570
- )
1950
+ ))
571
1951
  })?;
572
1952
  Ok(MeetingItem {
573
1953
  id,
@@ -580,6 +1960,172 @@ async fn create_adhoc_meeting(
580
1960
  })
581
1961
  }
582
1962
 
1963
+ /// Look for the ad-hoc row an earlier unanswered attempt may already have
1964
+ /// created for this call.
1965
+ ///
1966
+ /// Reads persisted rows only: a live calendar event cannot be what a
1967
+ /// `create-meeting` insert left behind, and asking for them would make this
1968
+ /// lookup depend on the calendar being reachable.
1969
+ async fn find_recent_adhoc_meeting(
1970
+ app: &AppHandle,
1971
+ client: &reqwest::Client,
1972
+ server_url: &str,
1973
+ session: &crate::meetings_watcher::MeetingsSessionSnapshot,
1974
+ platform: &str,
1975
+ since_ts: i64,
1976
+ ) -> Result<Option<MeetingItem>, String> {
1977
+ let now_ts = chrono::Utc::now().timestamp();
1978
+ // Reach back to the earliest attempt still in doubt, plus a minute so the
1979
+ // window cannot close on the row it is looking for.
1980
+ let lookback_min = (((now_ts - since_ts).max(0) / 60) + 2).to_string();
1981
+
1982
+ // Walk pages until the window is covered. The agenda view is ascending from
1983
+ // the start of the lookback, so the row a lost response created can sit
1984
+ // behind a page boundary if enough meetings start ahead of it — and
1985
+ // answering "no such row" from a page that stopped short is what would
1986
+ // insert the duplicate. Bounded, because an unbounded walk over a calendar
1987
+ // with no upper bound would page through every future meeting on every
1988
+ // retry.
1989
+ for page in 0..RECONCILE_MAX_PAGES {
1990
+ let offset = page * RECONCILE_PAGE_LIMIT;
1991
+ let rows = fetch_agenda_page(
1992
+ app,
1993
+ client,
1994
+ server_url,
1995
+ session,
1996
+ lookback_min.as_str(),
1997
+ offset,
1998
+ )
1999
+ .await?;
2000
+
2001
+ if let Some(found) = pick_recent_adhoc_meeting(&rows, platform, since_ts, now_ts) {
2002
+ return Ok(Some(found));
2003
+ }
2004
+ if reconcile_window_was_covered(&rows, now_ts) {
2005
+ return Ok(None);
2006
+ }
2007
+ }
2008
+
2009
+ // Ran out of pages with the window still open. Unresolved, not absent.
2010
+ Err(format!(
2011
+ "list-meetings did not reach the reconcile window within {RECONCILE_MAX_PAGES} pages"
2012
+ ))
2013
+ }
2014
+
2015
+ /// One page of persisted agenda rows.
2016
+ ///
2017
+ /// Persisted only: a live calendar event cannot be what a `create-meeting`
2018
+ /// insert left behind, and asking for them would make this lookup depend on the
2019
+ /// calendar being reachable.
2020
+ async fn fetch_agenda_page(
2021
+ app: &AppHandle,
2022
+ client: &reqwest::Client,
2023
+ server_url: &str,
2024
+ session: &crate::meetings_watcher::MeetingsSessionSnapshot,
2025
+ lookback_min: &str,
2026
+ offset: usize,
2027
+ ) -> Result<Vec<MeetingItem>, String> {
2028
+ let page_limit = RECONCILE_PAGE_LIMIT.to_string();
2029
+ let offset = offset.to_string();
2030
+ let url = format!("{server_url}/_agent-native/actions/list-meetings");
2031
+ let mut req = client
2032
+ .get(url)
2033
+ .query(&[
2034
+ ("view", "agenda"),
2035
+ ("agendaLookbackMin", lookback_min),
2036
+ ("includeLiveCalendar", "false"),
2037
+ ("limit", page_limit.as_str()),
2038
+ ("offset", offset.as_str()),
2039
+ ])
2040
+ .header("X-Request-Source", "clips-desktop");
2041
+ if let Some(cookie) = session.session_cookie.as_deref() {
2042
+ req = req.header("Cookie", cookie);
2043
+ }
2044
+ if let Some(token) = session.auth_token.as_deref() {
2045
+ req = req.bearer_auth(token);
2046
+ }
2047
+ let response = req
2048
+ .send()
2049
+ .await
2050
+ .map_err(|error| format!("list-meetings fetch: {error}"))?;
2051
+ if response.status() == reqwest::StatusCode::UNAUTHORIZED {
2052
+ let _ = app.emit("meetings:auth-needed", serde_json::json!({}));
2053
+ return Err("list-meetings http 401".to_string());
2054
+ }
2055
+ if !response.status().is_success() {
2056
+ return Err(format!("list-meetings http {}", response.status()));
2057
+ }
2058
+ let body: serde_json::Value = response
2059
+ .json()
2060
+ .await
2061
+ .map_err(|error| format!("list-meetings response: {error}"))?;
2062
+ // A 200 whose body is not a meetings list — an error payload, or a changed
2063
+ // envelope — is unreadable, not empty. `parse_meetings` flattens both to an
2064
+ // empty vector, and the caller is deciding whether to insert a row, so it
2065
+ // has to see the difference.
2066
+ crate::meetings_watcher::try_parse_meetings(&body).ok_or_else(|| {
2067
+ format!(
2068
+ "list-meetings response was not a meetings list: {}",
2069
+ body.to_string().chars().take(200).collect::<String>()
2070
+ )
2071
+ })
2072
+ }
2073
+
2074
+ /// Whether a returned page can be trusted to answer "no such row".
2075
+ ///
2076
+ /// A short page is the whole result, so it can. A full page was truncated, and
2077
+ /// only covers up to its newest row — if that is still behind the window's
2078
+ /// ceiling, the row a lost response created may be one page further on, and
2079
+ /// concluding "no row exists" from it would insert the duplicate.
2080
+ fn reconcile_window_was_covered(meetings: &[MeetingItem], now_ts: i64) -> bool {
2081
+ if meetings.len() < RECONCILE_PAGE_LIMIT {
2082
+ return true;
2083
+ }
2084
+ let newest = meetings
2085
+ .iter()
2086
+ .filter_map(|m| m.scheduled_start.as_deref())
2087
+ .filter_map(|start| chrono::DateTime::parse_from_rfc3339(start).ok())
2088
+ .map(|start| start.timestamp())
2089
+ .max();
2090
+ newest.is_some_and(|newest| newest >= now_ts + RECONCILE_SKEW_SECS)
2091
+ }
2092
+
2093
+ /// The ad-hoc row a lost `create-meeting` response would have left behind: same
2094
+ /// platform, `source == "adhoc"`, and scheduled inside the attempt window.
2095
+ ///
2096
+ /// Bounded at both ends. Rows older than the attempt belong to a previous call,
2097
+ /// and the agenda view runs "from the lookback onward" with no upper bound of
2098
+ /// its own, so it also returns *future* ad-hoc rows — a scheduled one would
2099
+ /// otherwise win on recency and collect this call's notes. Within the window the
2100
+ /// newest wins, so a run of attempts that duplicated before this guard existed
2101
+ /// still lands on the row the call is actually using.
2102
+ fn pick_recent_adhoc_meeting(
2103
+ meetings: &[MeetingItem],
2104
+ platform: &str,
2105
+ since_ts: i64,
2106
+ now_ts: i64,
2107
+ ) -> Option<MeetingItem> {
2108
+ let floor = since_ts - RECONCILE_SKEW_SECS;
2109
+ let ceiling = now_ts + RECONCILE_SKEW_SECS;
2110
+ meetings
2111
+ .iter()
2112
+ .filter(|m| m.source.as_deref() == Some("adhoc"))
2113
+ .filter(|m| {
2114
+ m.platform
2115
+ .as_deref()
2116
+ .is_some_and(|p| p.eq_ignore_ascii_case(platform))
2117
+ })
2118
+ .filter_map(|m| {
2119
+ let start = m.scheduled_start.as_deref()?;
2120
+ let ts = chrono::DateTime::parse_from_rfc3339(start)
2121
+ .ok()?
2122
+ .timestamp();
2123
+ (ts >= floor && ts <= ceiling).then_some((ts, m))
2124
+ })
2125
+ .max_by_key(|(ts, _)| *ts)
2126
+ .map(|(_, m)| m.clone())
2127
+ }
2128
+
583
2129
  async fn find_calendar_meeting(
584
2130
  app: &AppHandle,
585
2131
  client: &reqwest::Client,