Two log-spam bugs found in live gateway logs:
1. gateway_routing UNIQUE-constraint spam (261 warnings in one errors.log):
early builds of the #59203 routing-index migration created
gateway_routing with 'session_key TEXT PRIMARY KEY' and no scope
column. _reconcile_columns() ADDs the missing scope column but SQLite
cannot ALTER a primary key, so the shipped composite
PRIMARY KEY (scope, session_key) never lands on those databases. Both
write paths then fail on every save:
- save_gateway_routing_entry: 'ON CONFLICT clause does not match any
PRIMARY KEY or UNIQUE constraint'
- replace_gateway_routing_entries: 'UNIQUE constraint failed:
gateway_routing.session_key' whenever the same session_key exists
under another scope (e.g. test-suite scopes leaked into a live DB).
New _heal_gateway_routing_pk() rebuilds the table once with the
composite key, preserving rows (newest wins on collisions, NULL scope
coalesced to ''). Same one-time-heal pattern as the #51646 active-
column repair. Verified E2E against a copy of a real affected state.db.
2. pre_gateway_dispatch warned ''GatewayRunner' object has no attribute
'session_store'' and silently dropped the hook for every message on
partially-initialized runners (bare object.__new__ runners in tests,
and any future init-order change). Pass
getattr(self, 'session_store', None) so the hook always fires
(pitfall #17 pattern).
Both regression tests fail without their fixes (sabotage-verified).
Follow-up for salvaged #65023/#53020: _prepare_busy_steer_text now calls
_transcribe_and_echo_pending_voice (the same helper the interrupt monitor
and pending-drain paths use) instead of a private transcription+echo copy,
so out-of-band voice pays one STT call per platform message and the echo
respects the count-based ledger from #67281. can_steer now accepts events
whose attachments are all STT-eligible voice media, completing the steer
half of #58780. Adds extract_media gating tests for #44826 and the
contributor mapping for chefboyrdave21.
_invalidate_pending_stt_cache() clears the gateway-side transcription
cache when merge_pending_message_event() folds a follow-up message into a
still-pending event, so the next transcription picks up the merged text
and attachments. It also cleared _gateway_pending_stt_echo_sent, but that
flag is not derived state — it records that the transcript was already
delivered to the user.
Dropping it makes the re-run transcription echo the earlier notes a second
time. Both merge branches are affected, including the text-only follow-up
case where no new audio arrived at all: there the cache is invalidated,
the same voice note is transcribed again (a second paid STT call) and the
same line is echoed again.
Sequence:
1. voice note arrives, interrupt monitor transcribes it and echoes
'🎙️ "hello"'
2. user sends a follow-up while the turn is still pending, so it merges
3. drain path re-transcribes and echoes '🎙️ "hello"' a second time
Keep the ledger out of the invalidation set and track it as a count of
already-echoed transcripts instead of a single boolean. A count is what
the merge case actually needs: re-running transcription over the extended
media list returns the earlier transcripts as a prefix of the new one, so
echoing only the unsent tail suppresses the repeat while still surfacing a
newly merged voice note. A count rather than a set of seen values, so two
separate notes that transcribe identically stay two distinct deliveries —
covered by test_pending_stt_merge_echoes_two_identical_transcripts.
The guard stays within the 12-line window that
test_all_gateway_transcript_echo_sends_are_gated enforces over run.py.
Two hand-written fixes in the voice input path:
- _handle_voice_channel_input now resolves the bound text channel's
channel_prompt via the adapter's _resolve_channel_prompt so voice input
gets the same per-channel context as typed messages (fixes#50149).
- _enrich_message_with_transcription now guards success=True results whose
transcript is empty/whitespace-only (silence, cut-off, inaudible audio):
instead of emitting empty quotes the agent gets a clear sentinel note.
Reimplemented against the current plain-quoted note wording; original
concept and tests by @deacon-botdoctor in PR #41603 (fixes#41603).
Keep one owner for PATH/local-prefix ffmpeg discovery: ffmpeg_utils now
delegates to tools.transcription_tools._find_ffmpeg_binary and only adds
the Discord-specific FFMPEG_PATH override and Windows winget fallback on
top (follow-up to PR #60627 by @LauraGPT, fixes#60624).
Discord's voice socket needs a brief warm-up before receiving clients
actually hear audio; the first ~100-200ms is lost, clipping the first
word/syllable of TTS playback. Prepend a configurable lead of silence to
speech on both playback paths:
- Mixer path: new _lead_silence_bytes() helper prepends PCM silence
(BYTES_PER_MS constant added to voice_mixer.py) before play_speech, on
both the reply and the pre-tool ack.
- Legacy FFmpegPCMAudio path: apply -af adelay=<ms>:all=1.
Tunable via discord.voice_fx.lead_silence_ms (default 200, 0 disables).
Fixes#66827
- Wire adapter._voice_input_callback at connect and reconnect so voice
transcription is forwarded without requiring /voice join (#60623).
- Add optional text_channel_id and source params to DiscordAdapter
.join_voice_channel() so automatic/programmatic voice joins can
establish the text-channel binding needed by _handle_voice_channel_input.
- Add TestVoiceInputCallbackWiring: asserts callback wiring on startup
and reconnect for Discord adapters with voice attributes.
PR #61407 accesses self._handle_voice_channel_input in _platform_reconnect_watcher. The test mock runner created via _make_runner() must have this attribute.
- tests/gateway/test_base_auto_tts_output_format.py (new): the base
adapter auto-TTS block passes an explicit .ogg output path on every
OPUS_VOICE_PLATFORMS member (parametrized from the tts_tool set — the
single source of truth), keeps .mp3 on non-opus platforms, honors the
tool's success flag, and stays unique/uuid-based.
- tests/gateway/test_auto_voice_reply_format.py: runner _send_voice_reply
parametrized across Matrix/Feishu/WhatsApp/Signal (.ogg) alongside the
existing Telegram/Slack cases; streamed+global-auto-TTS gate regression.
- tests/gateway/test_telegram_voice_caption_markdown.py (new): caption
MarkdownV2 formatting, entity-rejection plain fallback, overflow skip,
and no-caption passthrough (#32029).
- test_voice_command.py filename-uuid contract test updated to point at
build_auto_tts_output_path (construction moved there).
Salvaged from PR #51196 (@55nx954gn6-debug). _should_send_voice_reply only
consulted the runner's _voice_mode dict (/voice on|voice_only|all), so the
global voice.auto_tts config default — which is synced into each adapter's
_auto_tts_default on gateway connect — was invisible to the runner path.
Net effect: with streaming enabled and only global auto-TTS configured
(no per-chat /voice opt-in), the streamed reply consumed the text, the
base adapter's auto-TTS got text_content=None, and no voice reply was
ever sent (#51867/#23983 remainder).
The runner now also asks the adapter's _should_auto_tts_for_chat(chat_id)
(which encodes per-chat /voice on|off overrides over the global default);
an explicit /voice off chat mode remains a hard override.
Refs #51867#23983#51282#13126
Salvaged from PR #68063 (@malaiwah). MatrixAdapter.send_voice now transcodes
any non-Ogg audio to Ogg/Opus at the adapter boundary (best-effort — the
original file is sent unchanged when ffmpeg is unavailable), so MSC3245
voice bubbles render even when a caller hands the adapter MP3/WAV audio.
_matrix_voice_metadata_for_file probing now runs via asyncio.to_thread so
ffprobe/ffmpeg subprocess timeouts can't stall the adapter event loop.
The PR's tools/tts_tool.py want_opus hunk was dropped: main's
OPUS_VOICE_PLATFORMS set (PR #73072) already includes matrix; the Matrix
opus-routing test is kept.
Refs #14841
Salvaged from PR #68063 (base commit, runner-path hunks superseded by the
platform-aware OPUS_VOICE_PLATFORMS fix in this branch). Element and other
Matrix clients render voice bubbles more reliably when m.audio events carry
duration and waveform metadata; probe both best-effort via ffprobe/ffmpeg.
Review follow-up: the spoken script is for synthesis only. Caption
eligibility and payload stay on the original reply, so a long reply
whose normalized script fits the 1024 char limit is still delivered
in full as its own message. Adds the long-original/short-normalized
regression case to the auto-TTS caption tests.
iOS Signal delivers voice notes as MP4-container AAC carrying an audio
ftyp brand ("M4A "). `_guess_extension()` returned ".mp4" for every
`ftyp` file regardless of brand, so those attachments were cached as
documents instead of audio and STT rejected the upload:
API error: Error code: 400 - Invalid file format.
Supported formats: ['flac','m4a','mp3','mp4','mpeg','mpga','oga',
'ogg','wav','webm']
Read the 4-byte brand at offset 8 and return ".m4a" for audio brands
("M4A ", "M4B ") so they satisfy `_is_audio_ext()` and route to
`cache_audio_from_bytes()`. Video brands (isom/mp42/avc1/qt) still
return ".mp4".
This mirrors the existing brand/form-type disambiguation already used
in this function for RIFF (WEBP vs WAVE) and for ADTS AAC vs MP3.
Verified against a real iOS Signal voice note: pre-fix the upload was
rejected with the 400 above; post-fix the same bytes transcribe
successfully.
The grant_spent notice fired for every subscription user with top-up
funds the moment their cap was reached and camped in the CLI/TUI status
bar and desktop toasts with no action to take — the account keeps
working off top-up. Remove it everywhere:
- agent/credits_tracker.py: drop grant_cond + the emit/clear block;
the dev fixture state now (correctly) produces no notice
- TUI: keep the turn-start clear of credits.grant_spent as back-compat
for older backends that still emit the key
- Desktop: drop the demo step and stale comment references
- Docs/config comments: remove grant-spent from credits_notices text
- Tests updated: policy/cold-start now assert the key never fires
Usage bands, depleted, and restored notices are unchanged; /usage still
reports the full balance breakdown.
The structural AST walker in test_compression_session_id_persistence.py
only recursed into control-flow children found via iter_child_nodes(stmt).
When a session_entry.session_id assignment lives inside an `else` block
whose statements are all assigns (no nested If/Try/etc to trigger
_walk_node), the assignment was invisible to the walker and the test
florped with 'No assignments found'.
Walk the stmt itself when it is a control-flow node so its
body/orelse/finalbody (and Try handlers) are always expanded, regardless
of whether iter_child_nodes yields an inner control-flow child.
Manual /compress already persists the rotated child transcript first and
only then repoints the live session_entry; a False rewrite_transcript
return keeps the entry on the original session_id so the conversation
stays reachable. Session hygiene auto-compress did the opposite: it
rebound session_id (and lease/topic) first, then called rewrite_transcript
without checking the return value. On a failed write the live entry
already pointed at an empty child SID and the turn continued — permanent
silent conversation loss. Persist first; rebind only after success.
Critical fixes to salvaged PR #73020:
- Use SessionDB.append_message instead of raw INSERT INTO messages.
The original used wrong column names (session_key/created_at vs
session_id/timestamp) and bypassed FTS indexing, session metadata
updates, display_kind, and all other columns append_message handles.
- Use get_hermes_home() instead of hardcoded Path.home()/'.hermes'.
Profile-aware path resolution under HERMES_HOME override and active
profile isolation.
- Add 11 tests covering flush, recovery, serialisation, edge cases.
- Change await self._send_read_receipt to asyncio.create_task to avoid
blocking message dispatch on slow bridge responses (matches BlueBubbles
pattern). Up to 5s per-message latency eliminated.
- Update tests: assert_called_once_with instead of assert_awaited_once_with
since the receipt is now scheduled, not directly awaited.
- Add send_read_receipts documentation to whatsapp.md following the
BlueBubbles docs pattern.
Review finding: native gates per-message DM sessions behind
platforms.slack.extra.dm_top_level_threads_as_sessions; the relay lane
coupled session keying to reply_in_thread alone, so 'threaded replies +
one rolling session' was expressible on native but not here.
Adds the same knob to the relay subset (platforms.relay.extra.slack.
dm_top_level_threads_as_sessions, default true = per-message sessions,
unchanged behavior). false keeps thread-per-message reply placement but
skips the session stamp — one rolling DM session, legacy steer posture.
TDD: opt-out + default-unchanged tests written first.
Review finding (2026-07-28): every path through _strip_synthetic_dm_thread
returned metadata unmodified — the actual strip was removed when prompts
switched to trusting the run.py thread stamp, leaving a 50-line no-op and
four tests that passed against it (verified by reviewer's negative control).
- delete the function + its _send_prompt call site (verbatim pass-through
with a pointer comment to the single mode authority)
- rewrite the three pass-through tests as end-to-end placement contracts
(forward run.py's stamp untouched)
- NEW boundary tests pinning run.py._resolve_progress_thread_id itself:
flat mode suppresses the synthetic self-anchor / preserves real threads;
thread mode keeps the first-turn self-anchor. This is the cross-module
coupling the review flagged as unpinned — if the upstream suppression
regresses, these fail instead of prompts silently threading.
`@/Desktop` returned nothing while `@Desktop` worked. The leading slash
was always read literally, so the lookup went to the absolute `/Desktop`
— which doesn't exist — and dead-ended instead of finding the folder one
level down.
The `@` has already announced "this is a path", so the slash people type
next reads as a separator out of habit rather than a filesystem root.
Take the absolute meaning only when it resolves: the parent directory has
to exist, and a partially-typed segment has to match something in it.
Otherwise drop the slash and resolve from the cwd.
Real absolute paths are unaffected — `/usr`, `/etc/hos`, `/Users/...` all
pass the existence probe and keep their current behaviour. The guard
matters: stripping the slash unconditionally would let a repo-local
`etc/` shadow the real `/etc`.
flat_dm_status was speculative config (rubric violation): no user wants
'make my agent look dead', and the only real consumer of status
suppression was native's placement-contamination guard — which the relay
lane handles structurally (QA-6/7 send-side anchor strip, leak-guard
test), not via preference. Status now anchors whenever an inbound ts
exists, in both modes.
Docs: new website/docs/user-guide/messaging/relay.md — enterprise-only
relay lane page documenting the platforms.relay.extra.<platform> subset
shape (nested wins, flat fallback), the Slack reply_in_thread control,
and always-on liveliness. Kept out of the native slack.md on purpose:
relay controls are not Slack config.
Victor's correction: flat DMs CAN have a live thinking status. setStatus
on the triggering message's ts renders '… thinking'/per-tool phrases in
that message's thread-footer space and clears without leaving a message
artifact. Native suppresses this because ITS reply routing could inherit
the activated thread; the relay lane's flat-mode sends strip their
anchors explicitly (QA-6/7), so the status anchor cannot leak into reply
placement — proven by the new leak-guard test.
send_typing/stop_typing now anchor the status in flat mode too, gated by
platforms.relay.extra.slack.flat_dm_status (default ON; false restores
the fully anchorless posture). Thread mode unchanged.
A 2nd top-level DM while a turn was in flight resolved to the SAME
session key and steered the running turn ('Redirected current run')
instead of starting its own. Native SlackAdapter stamps thread_ts =
event.thread_ts or ts on EVERY inbound, so build_session_key isolates
each top-level message; the connector normalizes top-level messages with
thread_id=null and the relay lane never reproduced the stamp.
_stamp_slack_session_thread applies native parity on the inbound bridge:
top-level Slack message + thread-per-message mode => source.thread_id =
its own ts (fresh session, parallel turns). Real thread replies and flat
mode untouched (flat keeps the shared rolling DM session on purpose).
Also introduces the enterprise config shape for relay-fronted Slack:
platforms.relay.extra.slack.<subset of native Slack fields> (nested
object wins; legacy flat extra.reply_in_thread still honoured). All
reply_in_thread reads (send/typing/stop_typing/run.py progress) now
route through one resolver.
The fuzzy branch of `complete.path` ranked basenames from
`_list_repo_files`, which lists files only, so a directory was only ever
reachable by typing a `/` — `@Desktop` returned nothing at all. Rank each
ancestor directory alongside the files, and break same-tier ties toward
the folder so `@docs` leads with `docs/` rather than `docs.md`.
Outside a git repo the fallback `os.walk` compounded this: it can spend
the whole `_FUZZY_CACHE_MAX_FILES` budget inside one deep subtree before
reaching a sibling, hiding top-level folders entirely. Seed the scan with
a `listdir` of the root so immediate children are always candidates.
Three mechanisms to detect and notify when gateway sessions stall silently:
1. Mid-turn activity heartbeats stamped to SessionDB so hermes sessions list
and hermes status show progress during long turns without new message rows.
2. Stall watchdog: when a busy session has pending inbound and the shared
activity clock is idle past agent.session_stall_timeout (default 300),
log a WARNING and notify the user once to try /new. Notify-only; does
not kill the turn.
3. Compaction timeout: fenceless compress_context callers get a progress-aware
host budget (compression.context_timeout_seconds default 120 idle,
compression.context_total_ceiling_seconds default 600 ceiling). On timeout,
cancel via commit fence, skip compaction without dropping messages, and
continue the turn.
Closes#72016 (slices 1-3; slice 4 cumulative SSE stream-retry deadline
remains a follow-up).
Cherry-picked from PR #72424 by @fangliquanflq.
The connector's Slack sender threads on metadata ONLY: threadTs() reads
metadata.thread_id/thread_ts and never the frame's reply_to. base.py's
final-reply lane (and its stream-fallback 'first response' resend) builds
metadata from source.thread_id — None for a top-level DM — so its sends
carried reply_to as the sole threading signal and posted to the home
channel (2026-07-27 post-approval report; the 15:17:03 frame showed
meta_keys=['notify','user_id']).
After the QA-6 mode gate keeps the anchor, copy it into
metadata.thread_id so the wire carries the signal where the connector
reads it. Flat mode unaffected (anchor already nulled); explicit thread
metadata wins; non-Slack untouched.
Slack's thinking-status line (thread replies footer, plain chat:write —
no assistant scopes needed) is thread-only: the connector's typing case
no-ops without thread_ts. The typing lane's metadata has no anchor for a
top-level DM (base.py builds from source.thread_id = None), so every
status heartbeat was silently dropped — the trace showed typing frames
with meta_keys=['user_id'] only.
Cache the triggering message ts per chat on inbound (_capture_scope) and
synthesize metadata.thread_id on send_typing/stop_typing in
thread-per-message mode, mirroring native send_typing's
_resolve_thread_ts(metadata.message_id). Flat mode unchanged (#18859);
real-thread metadata wins over the cache; the clear frame targets the
same synthesized thread so the status never sticks.
Native Slack shows dynamic assistant-status text ('Finding answers…',
'is running pytest…') because SlackAdapter sets supports_status_text=True
and renders the set_status_text() phrase in send_typing. The relay lane
advertised nothing, so run.py's live-status lane never fed it phrases and
the connector fell back to the static default.
- supports_status_text: descriptor-gated property (Slack only; other
fronted platforms keep textless bubbles)
- send_typing: carry the stashed phrase as the typing op's content; omit
when unset (empty string is Slack's explicit clear, reserved for
stop_typing). Connector already renders content via
assistant.threads.setStatus (#154).
_resolve_reply_to_for_send dropped the triggering-ts reply_to on every
Slack DM with no metadata thread_id. But the final-reply lane (platforms/
base.py) builds metadata from source.thread_id only — None for a top-level
DM — so in thread-per-message mode that reply_to is the final reply's ONLY
threading signal, and stripping it exiled the final message to the DM root
while progress bubbles stayed threaded (sibling of the QA-5 prompt bug).
Mirror native _resolve_thread_ts: suppress the synthetic anchor only when
platforms.slack.extra.reply_in_thread=false. Flat mode behavior unchanged;
real threads and channels unchanged.