diff --git a/CHANGELOG.md b/CHANGELOG.md
index e5d813d7..33cfaa3a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,12 @@ and versions follow [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Added
+- **Say "stop" or "snooze" to a ringing alarm.** While a Sentient alarm rings, typing or saying "stop", "stop the
+ alarm", "turn it off", "snooze" or "snooze for 5 minutes" in a chat, by voice or in Telegram, Discord or WhatsApp
+ stops or snoozes it at once, with no model ("Alarm stopped.", "Snoozed until 7:39 AM."). A snooze is 9 minutes
+ unless you say how long or change "Snooze length" in Settings. The banner has a Snooze button too, and after a
+ snooze it shows until when, with Turn off. When no alarm is ringing, these words go to the assistant as before, so
+ "turn off the alarm" can still mean your house alarm (#390).
- **Sentient in group chats, safely.** You can now pair a Telegram group or a Discord server channel with the same
`/pair` code. Sentient answers only you there, when you mention it, reply to it or send a command. What other people
write is treated like an email from a stranger: Sentient can read it, but never takes orders from it, and after
diff --git a/desktop/src/components/shell/AlarmBanner.tsx b/desktop/src/components/shell/AlarmBanner.tsx
index 0ec12a23..f3f639c2 100644
--- a/desktop/src/components/shell/AlarmBanner.tsx
+++ b/desktop/src/components/shell/AlarmBanner.tsx
@@ -1,18 +1,24 @@
-/** #359 A ringing alarm: a banner with Stop while the chime plays (stores/alarm.ts). */
-import { IconAlarm, IconVolume, IconVolumeOff } from '@tabler/icons-react'
+/** #359 A ringing alarm: a banner with Stop and Snooze while the chime plays (stores/alarm.ts). #390: after a snooze,
+ * from here or by saying "snooze" anywhere, it says until when, with Turn off. */
+import { IconAlarm, IconAlarmSnooze, IconVolume, IconVolumeOff, IconX } from '@tabler/icons-react'
import { AnimatePresence, motion } from 'motion/react'
-import { Button } from '@/components/ui'
+import { Button, IconButton } from '@/components/ui'
+import { formatTime } from '@/lib/utils'
import { useAlarm } from '@/stores/alarm'
export function AlarmBanner() {
const ringing = useAlarm((s) => s.ringing)
+ const snoozed = useAlarm((s) => s.snoozed)
const playing = useAlarm((s) => s.playing)
const stop = useAlarm((s) => s.stop)
+ const snooze = useAlarm((s) => s.snooze)
+ const turnOff = useAlarm((s) => s.turnOff)
return (
{ringing && (
: }
{playing ? 'Ringing' : "Can't play sound"}
+ {ringing.reminderId && (
+
+ )}
)}
+ {!ringing && snoozed && (
+
+
+
+ )}
)
}
diff --git a/desktop/src/features/settings/SchemaForm.tsx b/desktop/src/features/settings/SchemaForm.tsx
index 780323a2..27637991 100644
--- a/desktop/src/features/settings/SchemaForm.tsx
+++ b/desktop/src/features/settings/SchemaForm.tsx
@@ -33,6 +33,11 @@ export const LABELS: Record = {
description:
'Simple requests like "set a timer for 10 minutes", "remind me at 6 pm to call Ravi", "wake me at 7" or "what\'s next on my calendar" happen at once, without the model. English for now.'
},
+ 'chat.alarm_snooze_minutes': {
+ label: 'Snooze length',
+ unit: 'min',
+ description: 'How long "snooze" puts off a ringing alarm, unless you say how long ("snooze for 5 minutes").'
+ },
// channels (#359)
'channels.deliver_reminders': {
label: 'Reminders and alarms on your phone too',
diff --git a/desktop/src/lib/api.ts b/desktop/src/lib/api.ts
index a5483215..0e5bbc32 100644
--- a/desktop/src/lib/api.ts
+++ b/desktop/src/lib/api.ts
@@ -122,6 +122,7 @@ import type {
RoleName,
Reminder,
ReminderStatus,
+ SnoozedAlarm,
RuleProposal,
SecretStatus,
SentientConfig,
@@ -902,7 +903,11 @@ export const api = {
// §23 timers and reminders (#150) -------------------------------------------------------------
reminders: {
list: (status: ReminderStatus | 'all' = 'scheduled') => http.get<{ reminders: Reminder[] }>('/api/reminders', { query: { status } }),
- cancel: (id: string) => http.post(`/api/reminders/${enc(id)}/cancel`)
+ cancel: (id: string) => http.post(`/api/reminders/${enc(id)}/cancel`),
+ /** Stop a ringing alarm (#390). */
+ stop: (id: string) => http.post(`/api/reminders/${enc(id)}/stop`),
+ /** Snooze a ringing alarm (#390); minutes default to the snooze setting. */
+ snooze: (id: string, minutes?: number) => http.post(`/api/reminders/${enc(id)}/snooze`, minutes ? { minutes } : {})
},
// §16 webhooks --------------------------------------------------------------------------
diff --git a/desktop/src/lib/events.ts b/desktop/src/lib/events.ts
index 098cc391..9f76dad0 100644
--- a/desktop/src/lib/events.ts
+++ b/desktop/src/lib/events.ts
@@ -183,6 +183,8 @@ export function installDomainEvents(qc: QueryClient): () => void {
if (e.data?.id === 'webhook') void qc.invalidateQueries({ queryKey: qk.hooks })
})
)
+ // #390: a ringing alarm stopped or snoozed anywhere (a chat, voice, a messaging app) updates the banner
+ offs.push(live.onDomain('reminder.updated', (e) => useAlarm.getState().updated(e.data)))
offs.push(live.onDomain('memory.updated', () => void qc.invalidateQueries({ queryKey: qk.memories.all })))
offs.push(live.onDomain('skill.updated', () => void qc.invalidateQueries({ queryKey: qk.skills.all })))
offs.push(live.onDomain('mcp.review', () => void qc.invalidateQueries({ queryKey: qk.integrations.mcp })))
diff --git a/desktop/src/lib/types.ts b/desktop/src/lib/types.ts
index 4fad5f75..de6a9156 100644
--- a/desktop/src/lib/types.ts
+++ b/desktop/src/lib/types.ts
@@ -2991,4 +2991,13 @@ export interface Reminder {
fired_at: ISODate | null
cancelled_at: ISODate | null
notification_id: string | null
+ /** An alarm: when it was stopped or snoozed while ringing (#390). */
+ stopped_at?: ISODate | null
+ /** An alarm made by a snooze: the id of the alarm that was ringing (#390). */
+ snoozed_from?: string | null
+}
+
+export interface SnoozedAlarm {
+ stopped: Reminder
+ reminder: Reminder
}
diff --git a/desktop/src/stores/alarm.ts b/desktop/src/stores/alarm.ts
index 269c0e83..2accb799 100644
--- a/desktop/src/stores/alarm.ts
+++ b/desktop/src/stores/alarm.ts
@@ -1,26 +1,48 @@
/**
* A ringing alarm (#359). An alarm's `reminder` notification (`payload.event = "alarm"`) starts a chime made with
- * Web Audio, so no sound file ships with the app. It rings until Stop, or for `MAX_RING_MS` at most.
+ * Web Audio, so no sound file ships with the app. It rings until Stop or Snooze, or for `MAX_RING_MS` at most.
+ *
+ * The engine knows which alarm rings too (#390): Stop and Snooze here tell it, and saying "stop" or "snooze" in a chat,
+ * by voice or in a messaging app stops or snoozes it there. Its `reminder.updated` events then update the banner:
+ * a snooze turns it into "Snoozed until 7:39 AM", a stop hides it.
*/
import { create } from 'zustand'
-import type { Notification } from '@/lib/types'
+import { api } from '@/lib/api'
+import type { Notification, Reminder } from '@/lib/types'
-const MAX_RING_MS = 3 * 60_000
+const MAX_RING_MS = 3 * 60_000 // the engine's RING_S
const PATTERN_MS = 1400
export interface RingingAlarm {
id: string
+ /** The alarm in the engine (`payload.reminder_id`), when the notification has one. */
+ reminderId?: string
title: string
message: string
startedAt: number
}
+export interface Snoozed {
+ /** The new alarm the snooze set. */
+ reminderId: string
+ title: string
+ until: string
+}
+
interface AlarmState {
ringing: RingingAlarm | null
+ snoozed: Snoozed | null
/** True while the sound is really playing (the audio output started). */
playing: boolean
ring: (n: Notification) => void
+ /** Stop button: silence it here and tell the engine. */
stop: () => void
+ /** Snooze button: silence it here and ask the engine for a snooze. */
+ snooze: () => void
+ /** Turn off a snoozed alarm (cancels the alarm the snooze set). */
+ turnOff: () => void
+ /** A `reminder.updated` event: a stop or snooze from anywhere (#390). */
+ updated: (r: Reminder) => void
}
let ctx: AudioContext | null = null
@@ -57,12 +79,22 @@ export function isAlarm(n: Notification): boolean {
return n.kind === 'reminder' && n.payload?.event === 'alarm'
}
+function reminderOf(n: Notification): string | undefined {
+ const id = n.payload?.reminder_id
+ return typeof id === 'string' && id ? id : undefined
+}
+
export const useAlarm = create((set, get) => ({
ringing: null,
+ snoozed: null,
playing: false,
ring: (n) => {
silence()
- set({ ringing: { id: n.id, title: n.title || 'Alarm', message: n.message, startedAt: Date.now() }, playing: false })
+ set({
+ ringing: { id: n.id, reminderId: reminderOf(n), title: n.title || 'Alarm', message: n.message, startedAt: Date.now() },
+ snoozed: null,
+ playing: false
+ })
try {
const audio = new AudioContext()
ctx = audio
@@ -80,11 +112,45 @@ export const useAlarm = create((set, get) => ({
set({ playing: false }) // no audio output: the card and the notification still show
}
limit = window.setTimeout(() => {
- if (get().ringing?.id === n.id) get().stop()
+ if (get().ringing?.id === n.id) {
+ silence() // the engine stops counting it as ringing at the same time
+ set({ ringing: null, playing: false })
+ }
}, MAX_RING_MS)
},
stop: () => {
+ const id = get().ringing?.reminderId
silence()
set({ ringing: null, playing: false })
+ if (id) void api.reminders.stop(id).catch(() => undefined) // already stopped elsewhere: nothing to do
+ },
+ snooze: () => {
+ const ringing = get().ringing
+ if (!ringing?.reminderId) return get().stop()
+ silence()
+ set({ ringing: null, playing: false })
+ void api.reminders.snooze(ringing.reminderId).then(
+ ({ reminder }) => {
+ if (!get().ringing) set({ snoozed: { reminderId: reminder.id, title: ringing.title, until: reminder.fire_at } })
+ },
+ () => undefined // it had stopped already
+ )
+ },
+ turnOff: () => {
+ const snoozed = get().snoozed
+ set({ snoozed: null })
+ if (snoozed) void api.reminders.cancel(snoozed.reminderId).catch(() => undefined)
+ },
+ updated: (r) => {
+ const { ringing, snoozed } = get()
+ if (ringing?.reminderId && r.snoozed_from === ringing.reminderId && r.status === 'scheduled') {
+ silence()
+ set({ ringing: null, playing: false, snoozed: { reminderId: r.id, title: ringing.title, until: r.fire_at } })
+ } else if (ringing?.reminderId === r.id && r.stopped_at) {
+ silence()
+ set({ ringing: null, playing: false })
+ } else if (snoozed?.reminderId === r.id && r.status !== 'scheduled') {
+ set({ snoozed: null }) // it was turned off, or it is ringing again
+ }
}
}))
diff --git a/docs/API.md b/docs/API.md
index f06b4ba9..2210c04b 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -104,7 +104,7 @@ Envelope: `{"type": "task.updated", "data": {...}, "ts": "..."}`
| `import.updated` | **MemoryImportJob** (section 19): an import from ChatGPT, Claude or OpenClaw started, read a batch or finished |
| `backup.updated` | `{what: "backup"\|"snapshot"\|"restore_staged"\|"restore_cancelled"\|"restore_dismissed", name?, source?}` (section 20): refetch `GET /api/backups` |
| `folders.updated` | `{id, removed?: true, progress?: {done, total}}` (section 22): a folder was added, removed or queued, or a look through it started, moved on (at most once a second) or ended; refetch `GET /api/folders` |
-| `reminder.updated` | **Reminder** (section 23): a timer or reminder was set, cancelled or went off |
+| `reminder.updated` | **Reminder** (section 23): a timer or reminder was set, cancelled or went off, or a ringing alarm was stopped or snoozed |
---
@@ -3346,6 +3346,7 @@ language under `sentient/agent/fastpath/`, tried for `assistant.language` and th
| cancel | "cancel the timer", "stop the pasta timer", "cancel the 10 minute timer", "cancel my reminder to stretch", "cancel all timers", "turn off the gym alarm" | two or more match and "all" was not said; nothing matches the words given; a plural without "all" |
| home (#175) | "turn off the living room lights", "turn on the bedroom lamp", "living room lights off", "turn the lights in the kitchen off", "switch off all the lights", "dim the living room lights to 30%", "turn on the coffee maker" | Home Assistant not connected or not reachable; the words name no room or device exactly (one light named that, the lights of the room named that, or the lights whose names hold every word); "the bedroom light" when the bedroom has two; "the lights" without a room or "all"; a device left out by the entity picker; locks, alarms, doors, garage doors and gates, and switches that sound like one (never quick); a question ("is the porch light on") |
| alarm (#359) | "wake me at 7", "wake me up at 6:30 am", "set an alarm for 6:30", "set an alarm for 6:30 tomorrow", "set a 6:30 alarm", "set an alarm for 7 called gym", "wake me up in 20 minutes" | a repeating wish, a day name, "today" at a time that has passed, "tomorrow morning" |
+| stop or snooze a ringing alarm (#390) | only while a Sentient alarm rings: "stop", "stop the alarm", "turn it off", "turn off the gym alarm", "I'm up" (`stop_alarm`, "Alarm stopped."); "snooze", "snooze for 5 minutes", "snooze 10", "5 more minutes" (`snooze_alarm`, "Snoozed until 7:39 AM.") | nothing ringing ("stop the alarm" may mean a house alarm, #175: the usual commands and then the model have it); the words name another alarm ("stop the pasta alarm"); a snooze that is unclear ("snooze for a bit") or over 3 hours |
| calendar (#359) | "what's next on my calendar", "what's my next meeting", "when is my next meeting", "what do I have next" | no calendar connected; anything more ("... and email it to Ravi"), another day ("what's on my calendar tomorrow"), a bare "what's next" |
An alarm is the one place a clock time without AM or PM is fine, as on a phone: "set an alarm for 6:30" rings at the
@@ -3372,16 +3373,25 @@ off with `chat.quick_commands`.
**Timers, reminders and alarms** (`sentient/reminders/`, table `reminders`). **Reminder**
`{id, kind: "timer"|"reminder"|"alarm", message, connector: ""|"to"|"about"|"that", label, duration_s, fire_at, status:
"scheduled"|"fired"|"cancelled", session_id, channel, language, source: "quick"|"chat"|"api", created_at, fired_at,
-cancelled_at, notification_id}`. `fire_at` is UTC; a clock time is read in the user's timezone
+cancelled_at, notification_id, stopped_at, snoozed_from}`. `fire_at` is UTC; a clock time is read in the user's timezone
(`assistant.timezone`). `duration_s` is a timer's length or a reminder's "in 20 minutes". `session_id` is the chat it
was set in (null in an incognito chat). A timer runs 1 second to 24 hours; a reminder or alarm can be up to a year
-ahead. An alarm has no `message`; its `label` names it ("gym").
+ahead. An alarm has no `message`; its `label` names it ("gym"). `stopped_at` is when a ringing alarm was stopped or
+snoozed; `snoozed_from` is set on an alarm made by a snooze, to the id of the alarm that was ringing (#390).
+
+**A ringing alarm (#390).** An alarm counts as ringing for 3 minutes after it goes off (as long as the window's chime
+plays), until it is stopped or snoozed: from the banner's **Stop** or **Snooze**, over REST, or by saying "stop" or
+"snooze" in any chat, by voice or in a messaging app (a quick command, above). A snooze stops it and sets a new alarm
+with the same `label`, `channel` and chat, `chat.alarm_snooze_minutes` from now (9 by default, 1 to 60), or the minutes
+said (1 minute to 3 hours). The engine publishes `reminder.updated` for the new alarm first and then for the one that
+stopped (`stopped_at` set), so the window turns the ringing banner into "Snoozed until 7:39 AM" (with **Turn off**,
+which cancels the snoozed alarm) or hides it when it stopped.
When one comes due, the engine sends a `reminder` notification (`notification.new`): title "Timer done" (message "Your
10 minute timer is done."), "Reminder" (message: what to remind about) or "Alarm" / "Alarm: gym" (message "It's
7:00 AM.", or when late "Your 7:00 AM alarm went off while Sentient wasn't running."), payload `{event:
"timer"|"reminder"|"alarm", reminder_id, fire_at, late, channel, session_id?}`. The window rings for an alarm: a
-banner with **Stop** and a chime (Web Audio, no sound file) that plays until Stop, for 3 minutes at most. One that came due while Sentient was not running goes off when it
+banner with **Stop** and **Snooze** and a chime (Web Audio, no sound file) that plays until Stop, for 3 minutes at most. One that came due while Sentient was not running goes off when it
starts, and says when it was due (`late: true`). A reminder set in a Telegram, Discord or WhatsApp chat is also sent
to that chat (the delivery is listed on the Activity timeline as "A timer, reminder or alarm you set"); one set
anywhere else stays on the desktop, unless `channels.deliver_reminders` is on (#359, off by default; "Reminders and
@@ -3399,6 +3409,10 @@ ones are forgotten after 30 days.
- `list_reminders(kind = "any")` (risk `read`; `kind` "timer", "reminder", "alarm" or "any") → `{status, reminders}`,
soonest first.
- `cancel_reminder(reminder_id)` (risk `write`) → `{status, reminder}`.
+- `stop_alarm()` (risk `write`, #390) → `{status, stopped: [Reminder]}`: stops every ringing alarm. `{error: "No Sentient
+ alarm is ringing right now."}` when none is (the model may then think of a house alarm, #175).
+- `snooze_alarm(minutes = 0)` (risk `write`, #390) → `{status, stopped, reminder}`: the alarm that rang and the new one.
+ `minutes` 0 is `chat.alarm_snooze_minutes`. With several ringing, the newest is snoozed and the others stop.
Each `reminder` in a result also has `fire_at_local` and `seconds_left`.
@@ -3408,4 +3422,9 @@ Each `reminder` in a result also has `fire_at_local` and `seconds_left`.
- `POST /api/reminders` `{kind: "timer"|"reminder"|"alarm", minutes?, at?, message?, label?}` → Reminder (`source: "api"`).
400 with a plain `detail` when it can't be set.
- `POST /api/reminders/{id}/cancel` → Reminder. 404 when unknown, 409 when it already went off or was cancelled.
+- `POST /api/reminders/{id}/stop` → Reminder (#390): stop that ringing alarm. 404 when unknown, 409 when it isn't
+ ringing.
+- `POST /api/reminders/{id}/snooze` `{minutes?}` → `{stopped: Reminder, reminder: Reminder}` (#390): snooze that ringing
+ alarm (`minutes` defaults to `chat.alarm_snooze_minutes`; the new alarm has `source: "api"`). 400 for a length
+ outside 1 minute to 3 hours, 404 when unknown, 409 when it isn't ringing.
- Event `reminder.updated` (section 1). The Activity timeline (section 21) lists them under `reminders`.
diff --git a/sentient/agent/fastpath/__init__.py b/sentient/agent/fastpath/__init__.py
index 0ec703e9..4ce6678f 100644
--- a/sentient/agent/fastpath/__init__.py
+++ b/sentient/agent/fastpath/__init__.py
@@ -5,7 +5,9 @@
10 minutes", "remind me at 6 pm to call Ravi", "cancel the pasta timer", "what timers do I have"), the plan holds the
tool calls to make (the same ``reminders`` tools the model uses) and the confirmation to say, and the turn runs them
with no model call: it takes milliseconds on every surface (chat, voice, Telegram, Discord, WhatsApp). Anything else,
-or anything that could mean two things, returns None and goes to the model unchanged.
+or anything that could mean two things, returns None and goes to the model unchanged. While a Sentient alarm rings,
+"stop", "turn it off" and "snooze" (#390) stop or snooze it (``stop_alarm``, ``snooze_alarm``); with nothing ringing
+they are not quick commands.
A quick command never sends, buys or deletes anything: it only starts, lists or cancels Sentient's own timers,
reminders and alarms, reads the connected calendar (``gcal_list_events``; with no calendar connected the model
@@ -109,6 +111,10 @@ async def plan(app: SentientApp, text: str, *, now: datetime | None = None) -> Q
return None
tz = get_tz(user_timezone_name(config.assistant.timezone))
local = (now or datetime.now(UTC)).astimezone(tz)
+ if reminders is not None:
+ ring = await _ringing_plan(app, text, local)
+ if ring is not None:
+ return ring
found = parse(text, local, lang=config.assistant.language, names=[config.assistant.name])
if found is None:
return None
@@ -160,6 +166,35 @@ async def plan(app: SentientApp, text: str, *, now: datetime | None = None) -> Q
return out
+async def _ringing_plan(app: SentientApp, text: str, now: datetime) -> QuickPlan | None:
+ """ "stop" or "snooze" while a Sentient alarm rings (#390). With nothing ringing these words are not a quick
+ command: "stop the alarm" may mean a house alarm (#175), so the usual parse (and then the model) has them."""
+ config = app.config
+ for pack in _packs(config.assistant.language):
+ cmd = pack.ringing(text, [config.assistant.name])
+ if cmd is not None:
+ break
+ else:
+ return None
+ ringing = await app.reminders.ringing(now.astimezone(UTC))
+ if not ringing or (cmd.which and not any(_matches(r, cmd.which, pack) for r in ringing)):
+ return None
+ out = QuickPlan(cmd, pack, now)
+ if cmd.action == "alarm_stop":
+ out.calls = [_call("stop_alarm", label=cmd.which) if cmd.which else _call("stop_alarm")]
+ out.reply = lambda results: pack.alarm_stopped(_list_of(results[0][0], "stopped"))
+ return out
+ minutes = cmd.seconds / 60 if cmd.seconds else config.chat.alarm_snooze_minutes
+ out.calls = [_call("snooze_alarm", minutes=int(minutes) if float(minutes).is_integer() else round(minutes, 2))]
+ out.reply = lambda results: pack.alarm_snoozed(_reminder_of(results[0][0]), now)
+ return out
+
+
+def _list_of(res: Any, key: str) -> list[dict]:
+ items = res.get(key) if isinstance(res, dict) else None
+ return [i for i in items or [] if isinstance(i, dict)]
+
+
async def _home(app: SentientApp, cmd: Command, pack: Language, now: datetime) -> QuickPlan | None:
"""Lights and switches through Home Assistant (#175), when it is connected and the words name exactly which."""
integrations = getattr(app, "integrations", None)
diff --git a/sentient/agent/fastpath/base.py b/sentient/agent/fastpath/base.py
index b42fac4e..a0e61697 100644
--- a/sentient/agent/fastpath/base.py
+++ b/sentient/agent/fastpath/base.py
@@ -21,14 +21,16 @@
@dataclass
class Command:
- action: str # "timer" | "reminder" | "alarm" | "cancel" | "list" | "home" | "calendar_next"
- seconds: float | None = None # a timer's length, or a reminder or alarm "in 20 minutes"
+ action: str # "timer" | "reminder" | "alarm" | "cancel" | "list" | "home" | "calendar_next" |
+ # "alarm_stop" | "alarm_snooze" (only while an alarm rings, #390)
+ seconds: float | None = None # a timer's length, a reminder or alarm "in 20 minutes", or a snooze's length
at: datetime | None = None # a reminder or alarm at a clock time (aware, in the user's timezone)
message: str = "" # what to remind about ("call Ravi")
connector: str = "" # how the message was asked for: "to", "about", "that" or "" (none)
label: str = "" # a timer's or an alarm's name ("pasta", "gym")
target: str = ANY # cancel / list: TIMER, REMINDER or ANY
- which: str = "" # cancel: words naming which one ("pasta", "10 minute", "stretch"); home: the device
+ which: str = "" # cancel, alarm_stop, alarm_snooze: words naming which one ("pasta", "10 minute",
+ # "stretch", "gym"); home: the device
# as the user named it ("living room lights", "bedroom lamp")
all: bool = False # cancel: every one of them; home: "all the lights"
power: str = "" # home: "on" or "off"
@@ -45,6 +47,17 @@ def parse(self, text: str, now: datetime, names: Sequence[str] = ()) -> Command
assistant is called, so "hey Sentient, ..." is understood."""
...
+ def ringing(self, text: str, names: Sequence[str] = ()) -> Command | None:
+ """While an alarm rings (#390): "stop", "turn it off" (``alarm_stop``) or "snooze", "snooze for 5 minutes"
+ (``alarm_snooze``, ``seconds`` when a length was said), or None. Never used when nothing rings."""
+ ...
+
+ def alarm_stopped(self, reminders: list[dict]) -> str: ...
+
+ def alarm_snoozed(self, reminder: dict, now: datetime) -> str:
+ """What to say after a snooze; ``reminder`` is the new alarm."""
+ ...
+
def duration(self, seconds: float) -> str: ...
def matches_duration(self, words: str, seconds: float) -> bool:
diff --git a/sentient/agent/fastpath/en.py b/sentient/agent/fastpath/en.py
index 648cbf69..658cf745 100644
--- a/sentient/agent/fastpath/en.py
+++ b/sentient/agent/fastpath/en.py
@@ -1,6 +1,6 @@
"""English quick commands (#150, #359): timers, one-off reminders and alarms, listing or cancelling them, and what's
next on the calendar; with Home Assistant connected, also lights and switches (#175): "turn off the living room
-lights", "dim the bedroom lamp to 30%".
+lights", "dim the bedroom lamp to 30%". While an alarm rings, "stop", "turn it off" and "snooze" (#390).
The grammar is deliberately narrow. A message must be *only* the command (with "please", "hey Sentient" and the
like around it); anything else, or anything that could mean two things, is left to the model:
@@ -531,6 +531,61 @@ def _home(s: str) -> Command | None:
return None
+# ---------------------------------------------------------------------------- a ringing alarm (#390)
+_RING_LEAD = r"(?:(?:ok(?:ay)?|alright|all\s+right|oh|ugh|yes|yeah|fine)\s+)*"
+_RING_IT = r"(?:it|that|this|alarm|(?:the|my|that|this)\s+(?:(?P[a-z0-9:' ]{1,30}?)\s+)?alarm)"
+_RING_STOP = [
+ re.compile(rf"^{_RING_LEAD}(?:stop|silence|dismiss|cancel|mute)(?:\s+{_RING_IT})?(?:\s+now)?$", re.I),
+ re.compile(rf"^{_RING_LEAD}(?:turn|switch|shut)\s+off\s+{_RING_IT}(?:\s+now)?$", re.I),
+ re.compile(rf"^{_RING_LEAD}(?:turn|switch|shut)\s+{_RING_IT}\s+off(?:\s+now)?$", re.I),
+ re.compile(rf"^{_RING_LEAD}(?:the\s+|my\s+)?alarm\s+off$", re.I),
+ re.compile(rf"^{_RING_LEAD}(?:shut\s+up|quiet|be\s+quiet|enough|that's\s+enough|i'?m\s+(?:up|awake)|"
+ r"i\s+am\s+(?:up|awake))(?:\s+now)?$", re.I),
+]
+_RING_SNOOZE = [
+ re.compile(rf"^{_RING_LEAD}snooze(?:\s+{_RING_IT})?(?:\s+(?:for\s+)?(?:another\s+)?(?P.+?))?$", re.I),
+ re.compile(rf"^{_RING_LEAD}(?:give\s+me\s+)?(?:another\s+)?(?P\S+)\s+more\s+(?Pminutes?|mins?)$", re.I),
+]
+SNOOZE_MAX_S = 180 * 60
+
+
+def _ring_which(m: re.Match) -> str:
+ return _clean_message(m.groupdict().get("which") or "").lower()
+
+
+def _snooze_length(words: str) -> float | None:
+ """Seconds in "5 minutes", "5 more minutes", "10" (minutes); None when unclear or not 1 minute to 3 hours."""
+ w = " ".join(re.sub(r"\bmore\b", " ", words.lower()).split())
+ if re.fullmatch(r"\d{1,3}", w):
+ w += " minutes"
+ seconds = parse_duration(w, days=False)
+ return seconds if seconds is not None and 60 <= seconds <= SNOOZE_MAX_S else None
+
+
+def ringing(text: str, names: Sequence[str] = ()) -> Command | None:
+ """ "stop", "stop the alarm", "turn it off", "I'm up" -> ``alarm_stop``; "snooze", "snooze for 5 minutes",
+ "5 more minutes" -> ``alarm_snooze``. Only asked while an alarm rings."""
+ s = _strip_polite(_normalize(text), names)
+ s = re.sub(r"\b(\w+)(?:\s+\1\b)+", r"\1", s, flags=re.I) # "stop stop stop"
+ if not s or len(s) > 80 or "\n" in (text or "").strip():
+ return None
+ for pat in _RING_STOP:
+ m = pat.match(s)
+ if m:
+ return Command("alarm_stop", which=_ring_which(m))
+ for pat in _RING_SNOOZE:
+ m = pat.match(s)
+ if m is None:
+ continue
+ found = m.groupdict()
+ words = f"{found['n']} {found['unit']}" if found.get("unit") else (found.get("dur") or "")
+ seconds = _snooze_length(words) if words else None
+ if words and seconds is None:
+ return None # "snooze for a bit", "snooze notifications": the model
+ return Command("alarm_snooze", seconds=seconds, which=_ring_which(m))
+ return None
+
+
def parse(text: str, now: datetime, names: Sequence[str] = ()) -> Command | None:
s = _strip_polite(_normalize(text), names)
if not s or len(s) > 300 or "\n" in (text or "").strip():
@@ -726,6 +781,14 @@ def alarm_set(reminder: dict, now: datetime) -> str:
return f"{head} set for {_alarm_day(at, now)}."
+def alarm_stopped(reminders: list[dict]) -> str:
+ return "Alarms stopped." if len(reminders) > 1 else "Alarm stopped."
+
+
+def alarm_snoozed(reminder: dict, now: datetime) -> str:
+ return f"Snoozed until {_clock_text(_local(reminder, now))}."
+
+
def alarm_due(reminder: dict, at: datetime, late: bool) -> tuple[str, str]:
label = str(reminder.get("label") or "")
title = f"Alarm: {label}" if label else "Alarm"
diff --git a/sentient/config/schema.py b/sentient/config/schema.py
index 40d3b5be..19cff2db 100644
--- a/sentient/config/schema.py
+++ b/sentient/config/schema.py
@@ -281,6 +281,11 @@ class ChatConfig(BaseModel):
"to call Ravi\", \"wake me at 7\") and \"what's next on my calendar\" at once, without asking the model. "
"Anything else still goes to the model. English for now.",
)
+ alarm_snooze_minutes: int = Field(
+ 9, ge=1, le=60,
+ description="How long \"snooze\" puts off a ringing alarm, in minutes, unless you say how long "
+ "(\"snooze for 5 minutes\").",
+ )
tool_selection: Literal["auto", "all"] = Field(
"auto",
description="auto: offer only the tools relevant to each message when there are more than the budget "
diff --git a/sentient/gateway/routes/reminders.py b/sentient/gateway/routes/reminders.py
index e505405b..1b43f68d 100644
--- a/sentient/gateway/routes/reminders.py
+++ b/sentient/gateway/routes/reminders.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
+from typing import NoReturn
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
@@ -50,6 +51,42 @@ async def add_reminder(request: Request, body: AddBody):
raise HTTPException(400, str(exc)) from exc
+class SnoozeBody(BaseModel):
+ minutes: float | None = None
+
+
+async def _ringing_or_error(request: Request, reminder_id: str) -> NoReturn:
+ item = await get_core(request).reminders.get(reminder_id)
+ if item is None:
+ raise HTTPException(404, "There is no alarm with that id.")
+ raise HTTPException(409, "That alarm isn't ringing.")
+
+
+@router.post("/{reminder_id}/stop")
+async def stop_alarm(request: Request, reminder_id: str):
+ """Stop a ringing alarm (#390), e.g. from the window's banner."""
+ stopped = await get_core(request).reminders.stop(reminder_id)
+ if not stopped:
+ await _ringing_or_error(request, reminder_id)
+ return stopped[0]
+
+
+@router.post("/{reminder_id}/snooze")
+async def snooze_alarm(request: Request, reminder_id: str, body: SnoozeBody | None = None):
+ """Snooze a ringing alarm (#390): it stops and a new alarm rings ``minutes`` from now (default
+ ``chat.alarm_snooze_minutes``)."""
+ core = get_core(request)
+ minutes = (body.minutes if body is not None else None) or core.config.chat.alarm_snooze_minutes
+ try:
+ done = await core.reminders.snooze(minutes, reminder_id, source="api")
+ except ReminderError as exc:
+ raise HTTPException(400, str(exc)) from exc
+ if done is None:
+ await _ringing_or_error(request, reminder_id)
+ rang, new = done
+ return {"stopped": rang, "reminder": new}
+
+
@router.post("/{reminder_id}/cancel")
async def cancel_reminder(request: Request, reminder_id: str):
item, changed = await get_core(request).reminders.cancel(reminder_id)
diff --git a/sentient/reminders/schema.sql b/sentient/reminders/schema.sql
index fecdcd54..3d4cea39 100644
--- a/sentient/reminders/schema.sql
+++ b/sentient/reminders/schema.sql
@@ -16,6 +16,8 @@ CREATE TABLE IF NOT EXISTS reminders (
created_at TEXT NOT NULL,
fired_at TEXT,
cancelled_at TEXT,
- notification_id TEXT
+ notification_id TEXT,
+ stopped_at TEXT, -- an alarm: when it was stopped or snoozed while ringing (#390)
+ snoozed_from TEXT -- an alarm set by a snooze: the id of the alarm that was ringing
);
CREATE INDEX IF NOT EXISTS idx_reminders_status ON reminders(status, fire_at);
diff --git a/sentient/reminders/service.py b/sentient/reminders/service.py
index 20b1a5ce..ff67d9c6 100644
--- a/sentient/reminders/service.py
+++ b/sentient/reminders/service.py
@@ -8,6 +8,10 @@
set anywhere else goes to the delivery chats too. An alarm's notification makes the window ring. One that came due while Sentient was not running fires
as soon as it starts, saying when it was due.
+An alarm counts as ringing for ``RING_S`` after it goes off (as long as the window rings), until it is stopped or
+snoozed (#390): from the banner, over REST, or by saying "stop" or "snooze" (a quick command). A snooze stops it and
+sets a new alarm with the same name, ``snoozed_from`` the one that rang.
+
Firing never asks the model, and it is not paused by Stop everything: a timer the user set still rings.
"""
@@ -33,6 +37,8 @@
KEEP_DAYS = 30 # fired and cancelled ones are forgotten after this long
LIST_LIMIT = 100
RETRY_S = 10.0 # a notification that could not be made is tried again after this long
+RING_S = 180.0 # an alarm rings this long at most (the window's chime, desktop stores/alarm.ts)
+SNOOZE_MIN, SNOOZE_MAX = 1, 180 # minutes a snooze can be
class ReminderError(ValueError):
@@ -45,6 +51,7 @@ def _row(r: Any) -> dict:
"duration_s": r["duration_s"], "fire_at": r["fire_at"], "status": r["status"], "session_id": r["session_id"],
"channel": r["channel"], "language": r["language"], "source": r["source"], "created_at": r["created_at"],
"fired_at": r["fired_at"], "cancelled_at": r["cancelled_at"], "notification_id": r["notification_id"],
+ "stopped_at": r["stopped_at"], "snoozed_from": r["snoozed_from"],
}
@@ -69,6 +76,9 @@ def __init__(self, app):
async def start(self) -> None:
from sentient.reminders.tools import RemindersPlugin
+ await self.app.store.ensure_column("reminders", "stopped_at", "TEXT") # #390
+ await self.app.store.ensure_column("reminders", "snoozed_from", "TEXT")
+
if not any(p.id == RemindersPlugin.id for p in self.app.registry.plugins()):
self.app.registry.register(RemindersPlugin())
if self.app.enable_background:
@@ -99,7 +109,7 @@ async def get(self, reminder_id: str) -> dict | None:
async def add(
self, kind: str, fire_at: datetime, *, message: str = "", connector: str = "", label: str = "",
duration_s: float | None = None, session_id: str | None = None, channel: str | None = None,
- language: str = "en", source: str = "chat",
+ language: str = "en", source: str = "chat", snoozed_from: str | None = None,
) -> dict:
if kind not in KINDS:
raise ReminderError("A reminder is a timer, a reminder or an alarm.")
@@ -118,13 +128,14 @@ async def add(
"label": " ".join(str(label or "").split())[:60], "duration_s": duration_s,
"fire_at": fire_at.astimezone(UTC).isoformat(), "status": "scheduled", "session_id": session_id,
"channel": channel, "language": language or "en", "source": source, "created_at": now_iso(),
- "fired_at": None, "cancelled_at": None, "notification_id": None,
+ "fired_at": None, "cancelled_at": None, "notification_id": None, "stopped_at": None,
+ "snoozed_from": snoozed_from,
}
await self.app.store.execute(
"INSERT INTO reminders(id, kind, message, connector, label, duration_s, fire_at, status, session_id, channel,"
- " language, source, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
+ " language, source, created_at, snoozed_from) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(item["id"], kind, item["message"], connector, item["label"], duration_s, item["fire_at"], "scheduled",
- session_id, channel, item["language"], source, item["created_at"]),
+ session_id, channel, item["language"], source, item["created_at"], snoozed_from),
)
log.info("%s %s set for %s (%s, from %s)", kind, item["id"][:8], item["fire_at"], source, channel or "?")
self._changed(item)
@@ -144,6 +155,73 @@ async def cancel(self, reminder_id: str) -> tuple[dict | None, bool]:
self._changed(item)
return item, changed
+ # ------------------------------------------------------------------ a ringing alarm (#390)
+ async def ringing(self, now: datetime | None = None) -> list[dict]:
+ """The alarms ringing now, newest first: gone off in the last ``RING_S`` and not stopped or snoozed."""
+ now = now or datetime.now(UTC)
+ rows = await self.app.store.fetchall(
+ "SELECT * FROM reminders WHERE kind = 'alarm' AND status = 'fired' AND stopped_at IS NULL"
+ " ORDER BY fired_at DESC"
+ )
+ out = []
+ for r in rows:
+ fired = _parse(r["fired_at"])
+ if fired is not None and 0 <= (now - fired).total_seconds() <= RING_S:
+ out.append(_row(r))
+ return out
+
+ async def _claim(self, item: dict, now: datetime) -> dict | None:
+ """Mark one ringing alarm stopped; None when something else stopped it first."""
+ cur = await self.app.store.execute(
+ "UPDATE reminders SET stopped_at = ? WHERE id = ? AND stopped_at IS NULL", (now.isoformat(), item["id"])
+ )
+ return {**item, "stopped_at": now.isoformat()} if cur.rowcount else None
+
+ async def stop(self, reminder_id: str | None = None, *, now: datetime | None = None) -> list[dict]:
+ """Stop the ringing alarms (every one, or only ``reminder_id``). Returns the ones stopped now."""
+ now = now or datetime.now(UTC)
+ stopped = []
+ for item in await self.ringing(now):
+ if reminder_id and item["id"] != reminder_id:
+ continue
+ done = await self._claim(item, now)
+ if done is not None:
+ log.info("alarm %s stopped", item["id"][:8])
+ self._changed(done)
+ stopped.append(done)
+ return stopped
+
+ async def snooze(
+ self, minutes: float, reminder_id: str | None = None, *, source: str = "chat", now: datetime | None = None
+ ) -> tuple[dict, dict] | None:
+ """Snooze the ringing alarm (the newest, or ``reminder_id``): it stops, and a new alarm with the same name rings
+ ``minutes`` from now. Without ``reminder_id`` every other ringing alarm stops too. Returns ``(the one that
+ rang, the new alarm)``, or None when nothing is ringing."""
+ if not SNOOZE_MIN <= float(minutes) <= SNOOZE_MAX:
+ raise ReminderError(f"A snooze can be {SNOOZE_MIN} minute to {SNOOZE_MAX // 60} hours.")
+ now = now or datetime.now(UTC)
+ ringing = [r for r in await self.ringing(now) if not reminder_id or r["id"] == reminder_id]
+ for item in ringing:
+ rang = await self._claim(item, now)
+ if rang is None:
+ continue
+ seconds = float(minutes) * 60
+ try:
+ new = await self.add(
+ "alarm", now + timedelta(seconds=seconds), label=item["label"], duration_s=seconds,
+ session_id=item["session_id"], channel=item["channel"], language=item["language"], source=source,
+ snoozed_from=item["id"],
+ )
+ except Exception: # no new alarm: it keeps ringing rather than being silently lost
+ await self.app.store.execute("UPDATE reminders SET stopped_at = NULL WHERE id = ?", (item["id"],))
+ raise
+ log.info("alarm %s snoozed for %s minutes", item["id"][:8], minutes)
+ self._changed(rang) # after the new alarm, so the window shows "snoozed" rather than nothing
+ if not reminder_id:
+ await self.stop(now=now)
+ return rang, new
+ return None
+
def _changed(self, item: dict) -> None:
self.app.bus.publish("reminder.updated", item)
self._wake.set()
diff --git a/sentient/reminders/tools.py b/sentient/reminders/tools.py
index 97a2df1c..c69c2510 100644
--- a/sentient/reminders/tools.py
+++ b/sentient/reminders/tools.py
@@ -146,11 +146,48 @@ async def cancel_reminder(ctx: ToolContext, reminder_id: str) -> dict:
return {"status": "success", "reminder": _shown(item, ctx)}
+@tool("stop_alarm", risk=Risk.write, internal=True)
+async def stop_alarm(ctx: ToolContext, label: str = "") -> dict:
+ """Stop the Sentient alarm that is ringing right now (one set with set_alarm). `label`: its name if the user said
+ one ("gym"), else "". Not for a house alarm."""
+ svc = _service(ctx)
+ if svc is None:
+ return {"error": "Alarms are not available right now."}
+ # a tool with at least one argument: qwen3 on Ollama often says it will call an argument-free tool and then doesn't
+ wanted = " ".join(str(label or "").lower().split())
+ ringing = [r for r in await svc.ringing() if not wanted or wanted in str(r.get("label") or "").lower()]
+ stopped = [item for r in ringing for item in await svc.stop(r["id"])]
+ if not stopped:
+ return {"error": f"No Sentient alarm{f' called {label}' if wanted else ''} is ringing right now."}
+ return {"status": "success", "stopped": [_shown(i, ctx) for i in stopped]}
+
+
+@tool("snooze_alarm", risk=Risk.write, internal=True)
+async def snooze_alarm(ctx: ToolContext, minutes: float = 0) -> dict:
+ """Snooze the Sentient alarm that is ringing right now: it stops and rings again `minutes` from now (0: the usual
+ snooze length). Not for a house alarm."""
+ svc = _service(ctx)
+ if svc is None:
+ return {"error": "Alarms are not available right now."}
+ from sentient.reminders.service import ReminderError
+
+ length = float(minutes or 0) or float(ctx.config.chat.alarm_snooze_minutes)
+ try:
+ done = await svc.snooze(length, source="quick" if ctx.extra.get("quick") else "chat")
+ except ReminderError as exc:
+ return {"error": str(exc)}
+ if done is None:
+ return {"error": "No Sentient alarm is ringing right now."}
+ rang, new = done
+ return {"status": "success", "stopped": _shown(rang, ctx), "reminder": _shown(new, ctx)}
+
+
class RemindersPlugin(ToolPlugin):
id = "reminders"
display_name = "Timers & reminders"
description = "Countdown timers, one-off reminders and alarms that notify you at the right time."
category = "core"
icon = "IconAlarm"
- selection_hint = "timers, countdowns, alarms, wake me up, remind me once at a time or in some minutes, cancel or list them"
- tools = [set_timer, set_reminder, set_alarm, list_reminders, cancel_reminder]
+ selection_hint = ("timers, countdowns, alarms, wake me up, remind me once at a time or in some minutes, cancel or list "
+ "them, stop or snooze a ringing alarm")
+ tools = [set_timer, set_reminder, set_alarm, list_reminders, cancel_reminder, stop_alarm, snooze_alarm]
diff --git a/tests/channels/test_quick_commands.py b/tests/channels/test_quick_commands.py
index 53241264..bf71948d 100644
--- a/tests/channels/test_quick_commands.py
+++ b/tests/channels/test_quick_commands.py
@@ -101,3 +101,24 @@ async def test_without_the_setting_a_desktop_alarm_stays_on_the_desktop(tg):
note = (await tg.app.notifications.list())[0]
assert await tg.app.channels.deliver_notification(note) == 0
assert len(tg.api.sent("sendMessage")) == before
+
+
+async def test_a_ringing_alarm_is_snoozed_and_stopped_from_telegram(tg, llm):
+ """#390: "snooze" and "stop" work the same in a paired chat, with no model call."""
+ await tg.pair(42)
+ item = await tg.app.reminders.add("alarm", datetime.now(UTC) - timedelta(seconds=1), channel="telegram", source="quick")
+ assert await tg.app.reminders.fire_due() == 1
+ before = len(tg.api.sent("sendMessage"))
+ await tg.say(42, "snooze for 5 minutes")
+ await until(lambda: any("Snoozed until" in m["text"] for m in tg.api.sent("sendMessage")[before:]))
+ assert llm.calls == []
+ new = (await tg.app.reminders.list(kind="alarm"))[0]
+ assert new["snoozed_from"] == item["id"] and new["channel"] == "telegram"
+
+ await tg.app.store.execute("UPDATE reminders SET fire_at = ? WHERE id = ?",
+ ((datetime.now(UTC) - timedelta(seconds=1)).isoformat(), new["id"]))
+ assert await tg.app.reminders.fire_due() == 1
+ before = len(tg.api.sent("sendMessage"))
+ await tg.say(42, "stop")
+ await until(lambda: any("Alarm stopped" in m["text"] for m in tg.api.sent("sendMessage")[before:]))
+ assert llm.calls == [] and await tg.app.reminders.ringing() == []
diff --git a/tests/test_ringing_alarm.py b/tests/test_ringing_alarm.py
new file mode 100644
index 00000000..1ad7dce3
--- /dev/null
+++ b/tests/test_ringing_alarm.py
@@ -0,0 +1,230 @@
+"""Stopping and snoozing a ringing alarm by saying so (#390): a quick command only while a Sentient alarm rings.
+
+With nothing ringing, "stop the alarm" may mean a house alarm (#175), so it reaches the model as before.
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+
+import pytest
+from fastapi.testclient import TestClient
+
+from sentient.agent import fastpath
+from sentient.agent.fastpath import en
+from sentient.app import SentientApp
+from sentient.gateway.app import create_app
+from sentient.llm.events import ToolCallEvent
+from sentient.reminders.service import RING_S
+from tests.conftest import FakeProvider
+from tests.test_quick_commands import _app, _reply, _turn
+
+STOPS = [
+ ("stop", ""), ("Stop!", ""), ("stop the alarm", ""), ("stop it", ""), ("turn it off", ""), ("turn that off", ""),
+ ("turn off the alarm", ""), ("switch the alarm off", ""), ("Hey Sentient, stop it please", ""), ("ok stop", ""),
+ ("stop stop stop", ""), ("I'm up", ""), ("silence the alarm", ""), ("dismiss", ""), ("alarm off", ""),
+ ("turn off the gym alarm", "gym"), ("stop my gym alarm", "gym"),
+]
+SNOOZES = [
+ ("snooze", None), ("Snooze.", None), ("snooze it", None), ("snooze the alarm", None),
+ ("snooze for 5 minutes", 300), ("snooze 10", 600), ("snooze it for ten min", 600),
+ ("snooze the alarm for 15 minutes", 900), ("5 more minutes", 300), ("give me 10 more minutes", 600),
+ ("please snooze for another 5 minutes", 300),
+]
+NOT_WHILE_RINGING = [ # even while an alarm rings, these are not about it
+ "stop the music", "turn off the lights", "snooze for a bit", "snooze notifications for an hour",
+ "snooze for 4 hours", "stop reminding me about the gym", "what time is it", "stop the timer",
+]
+
+
+@pytest.mark.parametrize(("text", "which"), STOPS)
+def test_stop_phrasings(text, which):
+ cmd = en.ringing(text, ["Sentient"])
+ assert cmd is not None and cmd.action == "alarm_stop" and cmd.which == which
+
+
+@pytest.mark.parametrize(("text", "seconds"), SNOOZES)
+def test_snooze_phrasings(text, seconds):
+ cmd = en.ringing(text, ["Sentient"])
+ assert cmd is not None and cmd.action == "alarm_snooze" and cmd.seconds == seconds
+
+
+@pytest.mark.parametrize("text", NOT_WHILE_RINGING)
+def test_other_words_are_not_about_the_ringing_alarm(text):
+ assert en.ringing(text, ["Sentient"]) is None
+
+
+def test_ring_words():
+ assert en.alarm_stopped([{}]) == "Alarm stopped."
+ assert en.alarm_stopped([{}, {}]) == "Alarms stopped."
+ now = datetime(2026, 10, 15, 7, 30, tzinfo=UTC)
+ assert en.alarm_snoozed({"fire_at": (now + timedelta(minutes=9)).isoformat()}, now) == "Snoozed until 7:39 AM."
+
+
+async def _ringing(s: SentientApp, label: str = "", channel: str = "desktop") -> dict:
+ """An alarm that has just gone off (fired with the real clock, so it counts as ringing)."""
+ item = await s.reminders.add("alarm", datetime.now(UTC) - timedelta(seconds=1), label=label, channel=channel,
+ source="quick")
+ assert await s.reminders.fire_due() >= 1
+ return item
+
+
+async def test_snooze_while_ringing_needs_no_model(config, isolated_home):
+ llm = FakeProvider()
+ s = await _app(config, isolated_home, llm)
+ try:
+ sid = await s.store.create_session(channel="desktop")
+ rang = await _ringing(s, "gym")
+ assert [r["id"] for r in await s.reminders.ringing()] == [rang["id"]]
+ events_seen: list[dict] = []
+ async with s.bus.subscribe() as q:
+ before = datetime.now(UTC)
+ events = await _turn(s, sid, "snooze")
+ while not q.empty():
+ ev = q.get_nowait()
+ if ev["type"] == "reminder.updated":
+ events_seen.append(ev["data"])
+ assert llm.calls == [] and llm.text_calls == []
+ call = next(e for e in events if isinstance(e, ToolCallEvent))
+ assert call.name == "snooze_alarm" and call.arguments == {"minutes": 9}
+ assert _reply(events).startswith("Snoozed until ")
+ assert await s.reminders.ringing() == []
+ new = (await s.reminders.list(kind="alarm"))[0]
+ assert new["snoozed_from"] == rang["id"] and new["label"] == "gym" and new["status"] == "scheduled"
+ left = (datetime.fromisoformat(new["fire_at"]) - before).total_seconds()
+ assert 9 * 60 - 1 <= left <= 9 * 60 + 5
+ assert (await s.reminders.get(rang["id"]))["stopped_at"]
+ # the window learns of the new alarm first, then that the ringing one stopped
+ assert [e["id"] for e in events_seen] == [new["id"], rang["id"]]
+ assert events_seen[1]["stopped_at"] and events_seen[0]["snoozed_from"] == rang["id"]
+
+ # a snooze is a real alarm: it rings again, and "stop" silences it
+ assert await s.reminders.fire_due(datetime.fromisoformat(new["fire_at"])) == 1
+ await s.store.execute("UPDATE reminders SET fired_at = ? WHERE id = ?", (datetime.now(UTC).isoformat(), new["id"]))
+ events = await _turn(s, sid, "stop")
+ assert llm.calls == [] and _reply(events) == "Alarm stopped."
+ assert await s.reminders.ringing() == []
+ finally:
+ await s.stop()
+
+
+async def test_snooze_for_the_minutes_said_or_the_setting(config, isolated_home):
+ s = await _app(config, isolated_home, chat__alarm_snooze_minutes=15)
+ try:
+ sid = await s.store.create_session(channel="desktop")
+ await _ringing(s)
+ events = await _turn(s, sid, "snooze for 5 minutes")
+ assert next(e for e in events if isinstance(e, ToolCallEvent)).arguments == {"minutes": 5}
+ await s.reminders.cancel((await s.reminders.list())[0]["id"])
+ await _ringing(s)
+ events = await _turn(s, sid, "snooze it")
+ assert next(e for e in events if isinstance(e, ToolCallEvent)).arguments == {"minutes": 15}
+ finally:
+ await s.stop()
+
+
+async def test_with_nothing_ringing_the_words_go_to_the_model(config, isolated_home):
+ """#175: "stop the alarm" may mean the house alarm. Nothing ringing: the model answers, as before."""
+ llm = FakeProvider(replies=["Which alarm do you mean?"] * 4)
+ s = await _app(config, isolated_home, llm)
+ try:
+ sid = await s.store.create_session(channel="desktop")
+ for text in ("stop the alarm", "stop", "snooze", "turn it off"):
+ assert await fastpath.plan(s, text) is None
+ assert _reply(await _turn(s, sid, "stop the alarm")) == "Which alarm do you mean?"
+ assert len(llm.calls) == 1
+
+ # an alarm that went off longer ago than the window rings has stopped by itself
+ old = await _ringing(s)
+ await s.store.execute("UPDATE reminders SET fired_at = ? WHERE id = ?",
+ ((datetime.now(UTC) - timedelta(seconds=RING_S + 5)).isoformat(), old["id"]))
+ assert await s.reminders.ringing() == []
+ assert await fastpath.plan(s, "stop") is None
+ finally:
+ await s.stop()
+
+
+async def test_naming_another_alarm_is_not_the_ringing_one(config, isolated_home):
+ s = await _app(config, isolated_home)
+ try:
+ await _ringing(s, "gym")
+ plan = await fastpath.plan(s, "turn off the gym alarm")
+ assert plan is not None and plan.calls[0].name == "stop_alarm"
+ assert await fastpath.plan(s, "stop the pasta alarm") is None
+ finally:
+ await s.stop()
+
+
+async def test_a_never_rule_sends_it_to_the_model(config, isolated_home):
+ llm = FakeProvider(replies=["I can't do that."])
+ s = await _app(config, isolated_home, llm)
+ try:
+ s.config.tools.approvals.rules = {"reminders": "never"}
+ sid = await s.store.create_session(channel="desktop")
+ await _ringing(s)
+ await _turn(s, sid, "stop")
+ assert len(llm.calls) == 1 and len(await s.reminders.ringing()) == 1
+ finally:
+ await s.stop()
+
+
+async def test_the_model_can_stop_and_snooze_too(config, isolated_home):
+ s = await _app(config, isolated_home)
+ try:
+ ctx = s.agent.tool_context("x", "desktop")
+ stop, snooze = s.registry.get("stop_alarm"), s.registry.get("snooze_alarm")
+ assert (await stop.fn(ctx)) == {"error": "No Sentient alarm is ringing right now."}
+ await _ringing(s)
+ assert (await snooze.fn(ctx, minutes=500))["error"].startswith("A snooze can be")
+ done = await snooze.fn(ctx, minutes=2)
+ assert done["status"] == "success" and done["reminder"]["source"] == "chat"
+ assert (await snooze.fn(ctx)) == {"error": "No Sentient alarm is ringing right now."}
+ finally:
+ await s.stop()
+
+
+async def test_a_snooze_that_cannot_set_its_alarm_leaves_it_ringing(config, isolated_home, monkeypatch):
+ s = await _app(config, isolated_home)
+ try:
+ rang = await _ringing(s)
+
+ async def broken(*args, **kwargs):
+ raise RuntimeError("disk full")
+
+ monkeypatch.setattr(s.reminders, "add", broken)
+ with pytest.raises(RuntimeError):
+ await s.reminders.snooze(9)
+ assert [r["id"] for r in await s.reminders.ringing()] == [rang["id"]]
+ finally:
+ await s.stop()
+
+
+async def test_stop_silences_every_ringing_alarm(config, isolated_home):
+ s = await _app(config, isolated_home)
+ try:
+ await _ringing(s)
+ await _ringing(s, "gym")
+ sid = await s.store.create_session(channel="desktop")
+ assert _reply(await _turn(s, sid, "stop")) == "Alarms stopped."
+ assert await s.reminders.ringing() == []
+ finally:
+ await s.stop()
+
+
+def test_rest_stop_and_snooze(config, isolated_home, monkeypatch):
+ monkeypatch.setenv("SENTIENT_GATEWAY_TOKEN", "test-token")
+ core = SentientApp(config, llm=FakeProvider(), db_path=isolated_home / "r.db", enable_background=False)
+ app = create_app(core)
+ with TestClient(app) as c:
+ c.headers.update({"Authorization": "Bearer test-token"})
+ made = c.post("/api/reminders", json={"kind": "alarm", "minutes": 30, "label": "gym"}).json()
+ assert c.post(f"/api/reminders/{made['id']}/stop").status_code == 409 # not ringing yet
+ assert c.post("/api/reminders/nope/snooze").status_code == 404
+ c.portal.call(core.store.execute, "UPDATE reminders SET fire_at = ? WHERE id = ?",
+ ((datetime.now(UTC) - timedelta(seconds=1)).isoformat(), made["id"]))
+ assert c.portal.call(core.reminders.fire_due) == 1
+ snoozed = c.post(f"/api/reminders/{made['id']}/snooze", json={"minutes": 3}).json()
+ assert snoozed["stopped"]["id"] == made["id"] and snoozed["stopped"]["stopped_at"]
+ assert snoozed["reminder"]["snoozed_from"] == made["id"] and snoozed["reminder"]["source"] == "api"
+ assert c.post(f"/api/reminders/{made['id']}/stop").status_code == 409 # already snoozed
+ assert c.post(f"/api/reminders/{made['id']}/snooze", json={"minutes": 999}).status_code == 400