From 3493a6c73c282e8d2fd0ed8fdb1caf11d204b561 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Tue, 21 Jul 2026 23:13:50 -0400 Subject: [PATCH 001/238] fix(desktop): feed memory.provider dropdown from live discovery The desktop Settings memory-provider dropdown read a hardcoded `ENUM_OPTIONS['memory.provider'] = ['', 'honcho', 'hindsight']` list, so user-installed and pip-installed providers never appeared even though the backend already discovers them (`GET /api/memory` -> `_discover_memory_provider_statuses()`) and the CLI (`hermes memory setup`) lists them. This was the one surface left where the memory config stack was not schema/discovery-driven. Fetch `getMemoryStatus()` on the settings page (mirroring the existing `elevenLabsVoiceOptions` pattern) and pass the discovered provider names to `enumOptionsFor` as `dynamicOptions` for the `memory.provider` key. The static `ENUM_OPTIONS` entry is demoted to a pre-load fallback; the current-value passthrough still keeps a selected-but-undiscovered provider visible. Completes the desktop half of the schema-driven memory-provider config surface (the CLI + backend + generic panel already landed via #51020 / #67206), superseding the stale #48675 which built the same feature against the pre-refactor layout. Co-authored-by: brooklyn! <770929+OutThisLife@users.noreply.github.com> --- .../src/app/settings/config-settings.tsx | 31 +++++++++++++++++-- apps/desktop/src/app/settings/constants.ts | 4 ++- apps/desktop/src/app/settings/helpers.test.ts | 19 ++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index 1d8a2de0127f..b4bb6a82d0e4 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -5,7 +5,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { Button } from '@/components/ui/button' -import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes' +import { getElevenLabsVoices, getHermesConfigSchema, getMemoryStatus, saveHermesConfig } from '@/hermes' import { useI18n } from '@/i18n' import { $keepAwake, setKeepAwake } from '@/store/keep-awake' import { notify, notifyError } from '@/store/notifications' @@ -76,6 +76,10 @@ export function ConfigSettings({ const schema = schemaResponse?.fields ?? null const [elevenLabsVoiceOptions, setElevenLabsVoiceOptions] = useState(null) const [elevenLabsVoiceLabels, setElevenLabsVoiceLabels] = useState>({}) + // Discovered memory providers (bundled + user-installed + pip), so the + // memory.provider dropdown reflects what the backend actually serves rather + // than a hardcoded subset. null until the first fetch resolves. + const [memoryProviderOptions, setMemoryProviderOptions] = useState(null) const saveVersionRef = useRef(0) const savedDiscoverySignatureRef = useRef(undefined) const [saveVersion, setSaveVersion] = useState(0) @@ -126,6 +130,27 @@ export function ConfigSettings({ return () => void (cancelled = true) }, []) + useEffect(() => { + let cancelled = false + + getMemoryStatus() + .then(result => { + if (cancelled) { + return + } + + // Empty sentinel first (built-in only), then every discovered plugin. + setMemoryProviderOptions(['', ...result.providers.map(provider => provider.name)]) + }) + .catch(() => { + if (!cancelled) { + setMemoryProviderOptions(null) + } + }) + + return () => void (cancelled = true) + }, []) + useEffect(() => { if (!config || saveVersion === 0) { return @@ -308,7 +333,9 @@ export function ConfigSettings({ enumOptions={ key === 'tts.elevenlabs.voice_id' ? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined) - : enumOptionsFor(key, getNested(config, key), config) + : key === 'memory.provider' + ? enumOptionsFor(key, getNested(config, key), config, memoryProviderOptions ?? undefined) + : enumOptionsFor(key, getNested(config, key), config) } onChange={value => updateConfig(setNested(config, key, value))} optionLabels={key === 'tts.elevenlabs.voice_id' ? elevenLabsVoiceLabels : undefined} diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 055f72ac19a1..789e8fce3062 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -249,7 +249,9 @@ export const ENUM_OPTIONS: Record = { 'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'], // Built-in memory is not a provider plugin: the empty sentinel renders as // "Built-in only" and a legacy literal `builtin` value is only kept visible - // via enumOptionsFor's current-value passthrough (#49513). + // via enumOptionsFor's current-value passthrough (#49513). This static list + // is only a pre-load fallback: config-settings.tsx feeds enumOptionsFor the + // live discovered set (getMemoryStatus) so user-installed/pip providers show. 'memory.provider': ['', 'honcho', 'hindsight'], // Terminal execution backends — kept in sync with the dispatch ladder in // tools/terminal_tool.py::_create_environment (local/docker/singularity/ diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts index 33aa771d32b5..825bc2909502 100644 --- a/apps/desktop/src/app/settings/helpers.test.ts +++ b/apps/desktop/src/app/settings/helpers.test.ts @@ -44,6 +44,25 @@ describe('settings helpers', () => { expect(options).toEqual(['', 'honcho', 'hindsight', 'builtin']) }) + it('prefers the discovered provider set over the static fallback for memory.provider', () => { + // config-settings.tsx passes the live getMemoryStatus() providers as + // dynamicOptions; user-installed/pip providers must appear, not just the + // hardcoded honcho/hindsight subset. + const discovered = ['', 'honcho', 'hindsight', 'mem0', 'supermemory'] + const options = enumOptionsFor('memory.provider', '', {}, discovered) + + expect(options).toEqual(discovered) + }) + + it('keeps the current memory.provider value visible even if discovery omits it', () => { + // A provider selected in config but not (yet) returned by discovery must + // still render as the current selection rather than silently vanishing. + const discovered = ['', 'honcho'] + const options = enumOptionsFor('memory.provider', 'hindsight', {}, discovered) + + expect(options).toEqual(['', 'honcho', 'hindsight']) + }) + describe('isExternalMemoryProvider', () => { it('treats only real plugin names as external providers', () => { expect(isExternalMemoryProvider('honcho')).toBe(true) From baa3bf39156b6174bbc8219a123f6a285ca25ccd Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Wed, 22 Jul 2026 00:15:10 -0400 Subject: [PATCH 002/238] refactor: make memory.provider schema-driven instead of a 2nd fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review on #69077. The first pass added a second, heavier round-trip (`GET /api/memory` -> `_discover_memory_provider_statuses()`, which imports every provider module and probes install state) just to fill the desktop dropdown, and left `schema.options` for memory.provider dead — three sources of truth for one list. Root cause is narrower: the desktop schema *already* carried a discovery-driven `memory.provider` option list (`_SCHEMA_OVERRIDES` -> `_memory_provider_options()`), but `enumOptionsFor` returned the static `ENUM_OPTIONS['memory.provider']`, which shadowed `schema.options` in config-field.tsx. The only real gap was liveness: `_SCHEMA_OVERRIDES` is frozen at import time, so a provider installed mid-session never showed. Fix at the layer the rest of this stack already uses: - Backend: generalize `_schema_with_voice_provider_options` -> `_schema_with_dynamic_provider_options`, which now also recomputes `memory.provider` options per request (cheap plugin-dir scan via `_memory_provider_options`, plus current-value preservation). Fixes the same staleness for CLI + dashboard, not just desktop. - Frontend: drop the `memory.provider` entry from `ENUM_OPTIONS` so `enumOptionsFor` returns undefined and config-field consumes the discovery-driven `schema.options` directly. No new frontend round-trips. - Remove the now-unnecessary `getMemoryStatus()` fetch/state/wiring in config-settings.tsx (reverted to main). - Fix the stale `helpers.ts` comment ("schema omits memory.provider"). Tests: backend tests for the per-request merge (recomputes discovered providers; preserves a configured-but-undiscovered value); frontend test asserts enumOptionsFor no longer shadows the schema for memory.provider. Co-authored-by: brooklyn! <770929+OutThisLife@users.noreply.github.com> --- .../src/app/settings/config-settings.tsx | 31 +--------- apps/desktop/src/app/settings/constants.ts | 11 ++-- apps/desktop/src/app/settings/helpers.test.ts | 37 ++--------- apps/desktop/src/app/settings/helpers.ts | 2 +- hermes_cli/web_server.py | 61 ++++++++++++++----- tests/hermes_cli/test_web_server.py | 38 ++++++++++++ 6 files changed, 98 insertions(+), 82 deletions(-) diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index b4bb6a82d0e4..1d8a2de0127f 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -5,7 +5,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { Button } from '@/components/ui/button' -import { getElevenLabsVoices, getHermesConfigSchema, getMemoryStatus, saveHermesConfig } from '@/hermes' +import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes' import { useI18n } from '@/i18n' import { $keepAwake, setKeepAwake } from '@/store/keep-awake' import { notify, notifyError } from '@/store/notifications' @@ -76,10 +76,6 @@ export function ConfigSettings({ const schema = schemaResponse?.fields ?? null const [elevenLabsVoiceOptions, setElevenLabsVoiceOptions] = useState(null) const [elevenLabsVoiceLabels, setElevenLabsVoiceLabels] = useState>({}) - // Discovered memory providers (bundled + user-installed + pip), so the - // memory.provider dropdown reflects what the backend actually serves rather - // than a hardcoded subset. null until the first fetch resolves. - const [memoryProviderOptions, setMemoryProviderOptions] = useState(null) const saveVersionRef = useRef(0) const savedDiscoverySignatureRef = useRef(undefined) const [saveVersion, setSaveVersion] = useState(0) @@ -130,27 +126,6 @@ export function ConfigSettings({ return () => void (cancelled = true) }, []) - useEffect(() => { - let cancelled = false - - getMemoryStatus() - .then(result => { - if (cancelled) { - return - } - - // Empty sentinel first (built-in only), then every discovered plugin. - setMemoryProviderOptions(['', ...result.providers.map(provider => provider.name)]) - }) - .catch(() => { - if (!cancelled) { - setMemoryProviderOptions(null) - } - }) - - return () => void (cancelled = true) - }, []) - useEffect(() => { if (!config || saveVersion === 0) { return @@ -333,9 +308,7 @@ export function ConfigSettings({ enumOptions={ key === 'tts.elevenlabs.voice_id' ? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined) - : key === 'memory.provider' - ? enumOptionsFor(key, getNested(config, key), config, memoryProviderOptions ?? undefined) - : enumOptionsFor(key, getNested(config, key), config) + : enumOptionsFor(key, getNested(config, key), config) } onChange={value => updateConfig(setNested(config, key, value))} optionLabels={key === 'tts.elevenlabs.voice_id' ? elevenLabsVoiceLabels : undefined} diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 789e8fce3062..2ecdfb1e7e44 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -247,12 +247,11 @@ export const ENUM_OPTIONS: Record = { 'code_execution.mode': ['project', 'strict'], 'context.engine': ['compressor', 'default', 'custom'], 'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'], - // Built-in memory is not a provider plugin: the empty sentinel renders as - // "Built-in only" and a legacy literal `builtin` value is only kept visible - // via enumOptionsFor's current-value passthrough (#49513). This static list - // is only a pre-load fallback: config-settings.tsx feeds enumOptionsFor the - // live discovered set (getMemoryStatus) so user-installed/pip providers show. - 'memory.provider': ['', 'honcho', 'hindsight'], + // NOTE: memory.provider is intentionally NOT listed here. Its options are + // discovery-driven and served by the backend config schema (merged + // per-request in web_server._schema_with_dynamic_provider_options), so + // config-field consumes schema.options directly — a static list here would + // shadow that and hide user-installed/pip providers (#49513). // Terminal execution backends — kept in sync with the dispatch ladder in // tools/terminal_tool.py::_create_environment (local/docker/singularity/ // modal/daytona/ssh). Remote backends need extra env (image, tokens, host). diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts index 825bc2909502..860c89cb31f0 100644 --- a/apps/desktop/src/app/settings/helpers.test.ts +++ b/apps/desktop/src/app/settings/helpers.test.ts @@ -30,37 +30,12 @@ describe('settings helpers', () => { expect(fieldCopyForSchemaKey(FIELD_DESCRIPTIONS, 'desktop.repo_scan_exclude_paths')).toBeTruthy() }) - it('lists the desktop memory provider options in their declared order', () => { - const options = enumOptionsFor('memory.provider', '', {}) - - // Built-in memory is not a provider plugin; the empty sentinel is the - // only built-in-shaped entry (#49513). - expect(options).toEqual(['', 'honcho', 'hindsight']) - }) - - it('keeps a legacy literal builtin value visible as the current selection', () => { - const options = enumOptionsFor('memory.provider', 'builtin', {}) - - expect(options).toEqual(['', 'honcho', 'hindsight', 'builtin']) - }) - - it('prefers the discovered provider set over the static fallback for memory.provider', () => { - // config-settings.tsx passes the live getMemoryStatus() providers as - // dynamicOptions; user-installed/pip providers must appear, not just the - // hardcoded honcho/hindsight subset. - const discovered = ['', 'honcho', 'hindsight', 'mem0', 'supermemory'] - const options = enumOptionsFor('memory.provider', '', {}, discovered) - - expect(options).toEqual(discovered) - }) - - it('keeps the current memory.provider value visible even if discovery omits it', () => { - // A provider selected in config but not (yet) returned by discovery must - // still render as the current selection rather than silently vanishing. - const discovered = ['', 'honcho'] - const options = enumOptionsFor('memory.provider', 'hindsight', {}, discovered) - - expect(options).toEqual(['', 'honcho', 'hindsight']) + it('does not shadow the backend schema options for memory.provider', () => { + // memory.provider options are discovery-driven and served by the backend + // config schema (merged per-request); enumOptionsFor must return undefined + // so config-field consumes schema.options instead of a stale static list. + expect(enumOptionsFor('memory.provider', '', {})).toBeUndefined() + expect(enumOptionsFor('memory.provider', 'honcho', {})).toBeUndefined() }) describe('isExternalMemoryProvider', () => { diff --git a/apps/desktop/src/app/settings/helpers.ts b/apps/desktop/src/app/settings/helpers.ts index 8882f98e63ad..13be2c6f0bf7 100644 --- a/apps/desktop/src/app/settings/helpers.ts +++ b/apps/desktop/src/app/settings/helpers.ts @@ -113,7 +113,7 @@ export function inferFieldSchema(value: unknown): ConfigFieldSchema { return { type: 'string' } } -// Backend schema omits some declared keys (e.g. memory.provider); config presence is the availability signal. +// Backend schema omits some declared keys; config presence is the availability signal. export function sectionFieldEntries( schema: Record, config: HermesConfigRecord diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 82d2c0da6784..cf0ceaaf7ef9 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1136,28 +1136,59 @@ def _custom_provider_options( return names -def _schema_with_voice_provider_options() -> Dict[str, Dict[str, Any]]: - """Return CONFIG_SCHEMA with per-request voice provider options merged. +def _memory_provider_schema_options(cfg: Dict[str, Any]) -> List[str]: + """Discovered memory providers for a per-request schema merge. - Computed at request time (not import time) so options reflect the - CURRENT config.yaml — including providers added after the server - started, and the profile-scoped config when the request carries a - ``profile`` param. The module-level ``CONFIG_SCHEMA`` is never mutated; - entries that change are shallow-copied onto a copied mapping. + Reuses the cheap directory scan of :func:`_memory_provider_options` and + additionally preserves the currently-configured provider, so a value + selected in config but not (yet) discoverable — e.g. a plugin removed from + disk — never silently vanishes from the dropdown. + """ + options = _memory_provider_options() + current = _normalize_memory_provider_name( + cfg.get("memory", {}).get("provider") if isinstance(cfg.get("memory"), dict) else None + ) + if current and current not in options: + options = [*options, current] + return options + + +def _schema_with_dynamic_provider_options() -> Dict[str, Dict[str, Any]]: + """Return CONFIG_SCHEMA with per-request discovery-driven options merged. + + Some ``*.provider`` selects have options that are discovered at runtime + (voice backends via the tts/stt registries + config.yaml command + providers; memory providers via a plugin-dir scan). The module-level + ``_SCHEMA_OVERRIDES`` freezes those lists at import time, so a provider + installed after the server started never appears. This recomputes them at + request time — reflecting the CURRENT config.yaml, the profile-scoped + config when the request carries a ``profile`` param, and mid-session + plugin installs — for every surface that reads the schema (desktop, CLI, + dashboard), with no extra frontend round-trips. + + The module-level ``CONFIG_SCHEMA`` is never mutated; entries that change + are shallow-copied onto a copied mapping. """ try: cfg = load_config() except Exception: # pragma: no cover - schema must survive config errors return CONFIG_SCHEMA overlay: Dict[str, Dict[str, Any]] = {} - for kind in ("tts", "stt"): - key = f"{kind}.provider" + + def _merge(key: str, merged: List[str]) -> None: entry = CONFIG_SCHEMA.get(key) if not isinstance(entry, dict) or not isinstance(entry.get("options"), list): - continue - merged = _custom_provider_options(kind, list(entry["options"]), cfg) + return if merged != entry["options"]: overlay[key] = {**entry, "options": merged} + + for kind in ("tts", "stt"): + entry = CONFIG_SCHEMA.get(f"{kind}.provider") + if isinstance(entry, dict) and isinstance(entry.get("options"), list): + _merge(f"{kind}.provider", _custom_provider_options(kind, list(entry["options"]), cfg)) + + _merge("memory.provider", _memory_provider_schema_options(cfg)) + if not overlay: return CONFIG_SCHEMA fields = dict(CONFIG_SCHEMA) @@ -6187,11 +6218,11 @@ async def get_defaults(): @app.get("/api/config/schema") async def get_schema(profile: Optional[str] = None): - # Voice provider options are merged per-request so user-declared - # command providers (tts.providers.* / stt.providers.*) added after - # server start still show up, scoped to the requested profile's config. + # Discovery-driven provider options (voice command providers + memory + # provider plugins) are merged per-request so providers added after server + # start still show up, scoped to the requested profile's config. with _config_profile_scope(profile): - fields = _schema_with_voice_provider_options() + fields = _schema_with_dynamic_provider_options() return {"fields": fields, "category_order": _CATEGORY_ORDER} diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index c981efd43c29..d3e684bb3c2f 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -4633,6 +4633,44 @@ class TestBuildSchemaFromConfig: missing = set(list_memory_provider_names()) - options assert missing == set(), f"discovered providers missing from schema options: {missing}" + def test_dynamic_merge_recomputes_memory_provider_options(self, monkeypatch): + """The per-request schema merge re-discovers memory providers. + + The import-time _SCHEMA_OVERRIDES freezes the list at server start; + _schema_with_dynamic_provider_options must recompute it so a provider + installed mid-session is selectable without a restart. + """ + from hermes_cli import web_server + + monkeypatch.setattr(web_server, "load_config", lambda: {"memory": {"provider": "honcho"}}) + monkeypatch.setattr( + web_server, + "_memory_provider_options", + lambda: ["", "honcho", "hindsight", "freshly_installed"], + ) + + fields = web_server._schema_with_dynamic_provider_options() + + assert "freshly_installed" in fields["memory.provider"]["options"] + # The entry is copied, not mutated in place, and keeps its select type. + assert fields["memory.provider"]["type"] == "select" + assert web_server.CONFIG_SCHEMA["memory.provider"] is not fields["memory.provider"] + + def test_dynamic_merge_preserves_configured_memory_provider(self, monkeypatch): + """A configured-but-undiscovered provider stays visible as the selection. + + e.g. the plugin dir was removed but config still points at it — the + dropdown must not silently drop the active value. + """ + from hermes_cli import web_server + + monkeypatch.setattr(web_server, "load_config", lambda: {"memory": {"provider": "gone_from_disk"}}) + monkeypatch.setattr(web_server, "_memory_provider_options", lambda: ["", "honcho"]) + + fields = web_server._schema_with_dynamic_provider_options() + + assert "gone_from_disk" in fields["memory.provider"]["options"] + def test_approvals_mode_options_match_config_values(self): """approvals.mode select options must match the values accepted by config.py. From e60a4ccbf4a291a387fcd71f063f78fc98c0ff0b Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Wed, 22 Jul 2026 00:19:35 -0400 Subject: [PATCH 003/238] refactor: tidy dynamic schema-options merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup pass on the per-request provider-options merge — behavior unchanged: - collapse the duplicated entry-validation shared by merge() and its callers into a single guard inside merge() - read the configured memory provider in readable steps instead of a nested ternary - build the merged mapping as one {**base, **overlay} expression - space out logical blocks --- hermes_cli/web_server.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index cf0ceaaf7ef9..bfe31468aca0 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1145,11 +1145,14 @@ def _memory_provider_schema_options(cfg: Dict[str, Any]) -> List[str]: disk — never silently vanishes from the dropdown. """ options = _memory_provider_options() - current = _normalize_memory_provider_name( - cfg.get("memory", {}).get("provider") if isinstance(cfg.get("memory"), dict) else None - ) + + memory = cfg.get("memory") + configured = memory.get("provider") if isinstance(memory, dict) else None + current = _normalize_memory_provider_name(configured) + if current and current not in options: options = [*options, current] + return options @@ -1173,27 +1176,28 @@ def _schema_with_dynamic_provider_options() -> Dict[str, Dict[str, Any]]: cfg = load_config() except Exception: # pragma: no cover - schema must survive config errors return CONFIG_SCHEMA + overlay: Dict[str, Dict[str, Any]] = {} - def _merge(key: str, merged: List[str]) -> None: + def merge(key: str, options: List[str]) -> None: entry = CONFIG_SCHEMA.get(key) - if not isinstance(entry, dict) or not isinstance(entry.get("options"), list): - return - if merged != entry["options"]: - overlay[key] = {**entry, "options": merged} + + if isinstance(entry, dict) and isinstance(entry.get("options"), list) and options != entry["options"]: + overlay[key] = {**entry, "options": options} for kind in ("tts", "stt"): entry = CONFIG_SCHEMA.get(f"{kind}.provider") - if isinstance(entry, dict) and isinstance(entry.get("options"), list): - _merge(f"{kind}.provider", _custom_provider_options(kind, list(entry["options"]), cfg)) + existing = entry.get("options") if isinstance(entry, dict) else None - _merge("memory.provider", _memory_provider_schema_options(cfg)) + if isinstance(existing, list): + merge(f"{kind}.provider", _custom_provider_options(kind, list(existing), cfg)) + + merge("memory.provider", _memory_provider_schema_options(cfg)) if not overlay: return CONFIG_SCHEMA - fields = dict(CONFIG_SCHEMA) - fields.update(overlay) - return fields + + return {**CONFIG_SCHEMA, **overlay} class ConfigUpdate(BaseModel): From 08ea88f4f72fa32a6e4dafc094fb6cc9abf8098e Mon Sep 17 00:00:00 2001 From: harjoth Date: Wed, 22 Jul 2026 05:43:51 -0700 Subject: [PATCH 004/238] fix(agent): decay protected summaries after restart protect_first_n decay state (compression_count / _previous_summary) is in-memory only, so a gateway restart re-protected the persisted handoff summary and head fossils, growing the head unboundedly across restart+compaction cycles (#57814). _effective_protect_first_n now probes a bounded resumed-head window for a persisted handoff summary (by metadata or content prefix) and decays protection when one is found, before compress_start is computed. The first post-restart compaction self-heals: stacked summary fossils are folded into the next summary prompt instead of preserved verbatim, rehydration is rolled back on abort, deterministic fallback carries a bounded redacted previous-summary snapshot, and forced user-leading merged summaries keep the live tail request after the summary end marker so they stay rehydratable. Squashed reapply of the 12-commit series from PR #57835 onto current main (branch was 1210 commits behind; single add/add conflict with the task-snapshot grounding helpers resolved by keeping both). Fixes #57814. --- agent/context_compressor.py | 244 ++++++++--- tests/agent/test_context_compressor.py | 29 ++ ...t_context_compressor_summary_continuity.py | 398 +++++++++++++++++- 3 files changed, 617 insertions(+), 54 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 9597d9fbc2a3..45ede22c9b54 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -288,6 +288,12 @@ _HISTORICAL_SUMMARY_PREFIXES = ( "config, etc.) may reflect work described here — avoid repeating it:", ) +# Restart handoff detection should be early and bounded: it needs to catch the +# restored protected head plus a small cluster of already-stacked handoff/ack +# turns, but it must not treat arbitrary summary-looking live-tail messages as +# proof that this is a resumed compacted session. +_RESTART_HANDOFF_PROBE_EXTRA_MESSAGES = 4 + # Minimum tokens for the summary output _MIN_SUMMARY_TOKENS = 2000 # Proportion of compressed content to allocate for summary @@ -318,6 +324,7 @@ _SUMMARY_FAILURE_COOLDOWN_SECONDS = 600 # only meant to preserve continuity anchors from the dropped window, not to # become another unbounded transcript copy after the LLM summarizer failed. _FALLBACK_SUMMARY_MAX_CHARS = 8_000 +_FALLBACK_PREVIOUS_SUMMARY_MAX_CHARS = 3_000 _FALLBACK_TURN_MAX_CHARS = 700 _AUTO_FOCUS_MAX_TURNS = 3 _AUTO_FOCUS_TURN_MAX_CHARS = 260 @@ -2305,9 +2312,17 @@ class ContextCompressor(ContextEngine): ) previous_summary_note = "" if self._previous_summary: + previous_summary = redact_sensitive_text(self._previous_summary.strip()) + if len(previous_summary) > _FALLBACK_PREVIOUS_SUMMARY_MAX_CHARS: + previous_summary = ( + previous_summary[: _FALLBACK_PREVIOUS_SUMMARY_MAX_CHARS - 45].rstrip() + + "\n...[previous summary snapshot truncated]" + ) previous_summary_note = ( - "\n\nPrevious compaction summary was present and should still be treated as " - "background continuity context, but the latest LLM summary update failed." + "\n\n## Previous Summary Snapshot\n" + f"{previous_summary}\n\n" + "The previous compaction summary above remains background " + "continuity context because the latest LLM summary update failed." ) reason_text = f" Summary failure reason: {reason}." if reason else "" @@ -2978,11 +2993,15 @@ This compaction should PRIORITISE preserving all information related to the focu if text.startswith(prefix): text = text[len(prefix):].lstrip() break - # Strip the trailing end marker too — a rehydrated handoff body that - # keeps it would leak the boundary directive into the iterative-update + # Strip the end marker too — a rehydrated handoff body that keeps it + # would leak the boundary directive into the iterative-update # summarizer prompt (and the marker is re-appended on insertion anyway). - if text.endswith(_SUMMARY_END_MARKER): - text = text[: -len(_SUMMARY_END_MARKER)].rstrip() + # Forced user-leading merged summaries keep the live tail request after + # this marker, so truncate at the marker even when it is not the final + # content. + marker_idx = text.find(_SUMMARY_END_MARKER) + if marker_idx >= 0: + text = text[:marker_idx].rstrip() return text @classmethod @@ -3081,6 +3100,15 @@ This compaction should PRIORITISE preserving all information related to the focu "session with no user-authored turns" ) + @classmethod + def _is_context_summary_message(cls, message: Any) -> bool: + """Return True for summary handoff messages by metadata or content.""" + if not isinstance(message, dict): + return False + return cls._has_compressed_summary_metadata( + message + ) or cls._is_context_summary_content(message.get("content")) + @classmethod def _is_blank_user_turn(cls, message: Any) -> bool: """Return whether *message* is an empty, non-summary user-role echo.""" @@ -3239,6 +3267,24 @@ This compaction should PRIORITISE preserving all information related to the focu return grounded.strip() return f"{replacement}{body}".strip() + @classmethod + def _find_context_summaries( + cls, + messages: List[Dict[str, Any]], + start: int, + end: int, + ) -> list[tuple[int, str]]: + """Find handoff summaries inside a compression window.""" + summaries: list[tuple[int, str]] = [] + for idx in range(start, end): + content = messages[idx].get("content") + if cls._is_context_summary_message(messages[idx]): + summaries.append(( + idx, + cls._strip_summary_prefix(_content_text_for_contains(content)), + )) + return summaries + @classmethod def _find_latest_context_summary( cls, @@ -3247,10 +3293,9 @@ This compaction should PRIORITISE preserving all information related to the focu end: int, ) -> tuple[Optional[int], str]: """Find the newest handoff summary inside a compression window.""" - for idx in range(end - 1, start - 1, -1): - content = messages[idx].get("content") - if cls._is_context_summary_content(content): - return idx, cls._strip_summary_prefix(_content_text_for_contains(content)) + summaries = cls._find_context_summaries(messages, start, end) + if summaries: + return summaries[-1] return None, "" @classmethod @@ -3471,7 +3516,25 @@ This compaction should PRIORITISE preserving all information related to the focu idx += 1 return idx - def _effective_protect_first_n(self) -> int: + def _restart_handoff_probe_bounds( + self, + messages: List[Dict[str, Any]], + ) -> tuple[int, int]: + """Return the bounded transcript region that can indicate restart decay.""" + if not messages or self.protect_first_n <= 0: + return 0, 0 + first_non_system = 1 if messages[0].get("role") == "system" else 0 + return first_non_system, min( + len(messages), + first_non_system + + self.protect_first_n + + _RESTART_HANDOFF_PROBE_EXTRA_MESSAGES, + ) + + def _effective_protect_first_n( + self, + messages: Optional[List[Dict[str, Any]]] = None, + ) -> int: """``protect_first_n`` decayed across compression cycles. ``protect_first_n`` keeps the first N non-system messages verbatim so @@ -3483,9 +3546,24 @@ This compaction should PRIORITISE preserving all information related to the focu once, the early turns are already captured in the handoff summary, so there's no need to keep re-protecting them: decay to 0 (the system prompt is still always protected separately by _protect_head_size). + After a restart, infer that decayed state from handoff summaries in the + resumed-head region; disk-persisted restarts rely on the content prefix, + while the metadata branch covers in-process handoff messages. """ if self.compression_count >= 1 or self._previous_summary: return 0 + if messages and self.protect_first_n > 0: + # Probe only the early handoff shape created by a resumed compacted + # session. Summary-looking tail content should keep normal tail + # semantics and not decay the initial first-compaction protection. + first_non_system, restart_probe_end = self._restart_handoff_probe_bounds( + messages + ) + if any( + self._is_context_summary_message(msg) + for msg in messages[first_non_system:restart_probe_end] + ): + return 0 return self.protect_first_n def _protect_head_size(self, messages: List[Dict[str, Any]]) -> int: @@ -3511,7 +3589,7 @@ This compaction should PRIORITISE preserving all information related to the focu head = 0 if messages and messages[0].get("role") == "system": head = 1 - return head + self._effective_protect_first_n() + return head + self._effective_protect_first_n(messages) def _align_boundary_backward(self, messages: List[Dict[str, Any]], idx: int) -> int: """Pull a compress-end boundary backward to avoid splitting a @@ -3593,6 +3671,8 @@ This compaction should PRIORITISE preserving all information related to the focu msg.get("content") ): continue + if self._is_context_summary_message(msg): + continue if last_any < 0: last_any = i content = msg.get("content") @@ -4059,23 +4139,45 @@ This compaction should PRIORITISE preserving all information related to the focu return messages turns_to_summarize = messages[compress_start:compress_end] + # Snapshot the rehydration state so an aborted attempt below can roll + # it back. The self-heal scan mutates ``_previous_summary`` (populating + # it from a fossil, or discarding a stale cross-session one); if + # summary generation then aborts and returns the transcript unchanged, + # leaving that mutation behind would make the retry — still + # ``compression_count == 0`` but now with a truthy ``_previous_summary`` + # — take the narrow rescan, miss a beyond-window fossil, and discard the + # rehydrated state as cross-session leakage (#57835). + _previous_summary_before_scan = self._previous_summary # A persisted handoff summary can sit in the protected head after a # resume (commonly immediately after the system prompt). Search from - # the first non-system message through the compression window so we can - # rehydrate iterative-summary state without serializing that handoff as - # a new turn. Protected messages after the handoff remain live context, - # so only summarize messages that are both after the handoff and inside - # the current compression window. + # the first non-system message through the compression window. On the + # first compaction after a restart, extend through the full transcript + # so summaries that landed in the protected tail or drifted past the + # decay probe still rehydrate iterative-summary state instead of being + # copied forward as stacked fossils. summary_search_start = 1 if messages and messages[0].get("role") == "system" else 0 - summary_idx, summary_body = self._find_latest_context_summary( + summary_search_end = compress_end + if self.compression_count < 1 and not self._previous_summary: + summary_search_end = len(messages) + summary_search_end = min(len(messages), summary_search_end) + summary_indices: set[int] = set() + summary_idx = None + summary_body = None + tail_start = compress_end + summary_hits = self._find_context_summaries( messages, summary_search_start, - compress_end, + summary_search_end, ) real_user_present = self._transcript_has_real_user_turn(messages) - if summary_idx is not None: - if summary_body and not self._previous_summary: - self._previous_summary = summary_body + if summary_hits: + summary_idx = summary_hits[-1][0] + summary_body = summary_hits[-1][1] + if not self._previous_summary: + summary_bodies = [body for _, body in summary_hits if body] + if summary_bodies: + self._previous_summary = "\n\n".join(summary_bodies) + # Zero-user provenance (#64650) rides on the newest handoff hit. provenance = messages[summary_idx].get( COMPRESSED_SUMMARY_HAS_USER_TURN_KEY ) @@ -4090,7 +4192,19 @@ This compaction should PRIORITISE preserving all information related to the focu self._summary_has_user_turn = not ( summary_body and _NO_USER_TASK_SENTINEL in summary_body ) - turns_to_summarize = messages[max(compress_start, summary_idx + 1):compress_end] + summary_indices = {idx for idx, _ in summary_hits} + pre_summary_turns = [ + msg for idx, msg in enumerate( + messages[compress_start:summary_idx], + start=compress_start, + ) + if idx not in summary_indices + ] + turns_to_summarize = ( + pre_summary_turns + messages[summary_idx + 1:compress_end] + ) + if summary_idx >= compress_end: + tail_start = summary_idx + 1 elif self._previous_summary: # No handoff summary found in the current messages, but # _previous_summary is non-empty — it was set by a different @@ -4121,7 +4235,7 @@ This compaction should PRIORITISE preserving all information related to the focu self.threshold_percent * 100, self.threshold_tokens, ) - tail_msgs = n_messages - compress_end + tail_msgs = n_messages - tail_start logger.info( "Summarizing turns %d-%d (%d turns), protecting %d head + %d tail messages", compress_start + 1, @@ -4174,6 +4288,11 @@ This compaction should PRIORITISE preserving all information related to the focu telemetry["failure_class"] = "summary_network_failure" else: telemetry["failure_class"] = "summary_generation_aborted" + # Roll back the self-heal rehydration so this aborted attempt is a + # true no-op: the next attempt must re-run the full first-compaction + # scan instead of narrow-rescanning against a half-populated state + # and discarding a legitimately rehydrated fossil (#57835). + self._previous_summary = _previous_summary_before_scan if not self.quiet_mode: if self._last_summary_auth_failure: logger.warning( @@ -4214,8 +4333,8 @@ This compaction should PRIORITISE preserving all information related to the focu # _strip_context_summary_handoff_message() handles both shapes: # standalone handoffs strip to None (dropped), merged handoffs # unwrap to their genuine prior-tail content (preserved). Do NOT - # short-circuit on summary_idx here: a merged handoff carries real - # user content that a blanket skip would silently delete. + # short-circuit on summary_indices here: a merged handoff carries + # real user content that a blanket skip would silently delete. msg = _fresh_compaction_message_copy(messages[i]) if i == 0 and msg.get("role") == "system": existing = msg.get("content") @@ -4246,17 +4365,26 @@ This compaction should PRIORITISE preserving all information related to the focu ) tail_messages: List[Dict[str, Any]] = [] - for i in range(compress_end, n_messages): + # Start at tail_start (not compress_end): the restart-decay scan may + # have advanced it past a summary that sat beyond compress_end + # (#57835). summary_indices rows are already rehydrated; the strip + # helper handles any that remain (standalone → dropped, merged → + # unwrapped to genuine prior-tail content, #47274). + for i in range(max(compress_end, tail_start), n_messages): + if i in summary_indices and i >= tail_start: + # A summary at/after tail_start was already folded into + # _previous_summary; don't re-emit it verbatim. + continue msg = _fresh_compaction_message_copy(messages[i]) stripped = self._strip_context_summary_handoff_message(msg) if stripped is not None: tail_messages.append(stripped) _merge_summary_into_tail = False - last_head_role = compressed[-1].get("role", "user") if compressed else "user" - # NOTE: derive the tail's leading role from tail_messages (post - # handoff-strip), not messages[compress_end] — a stripped stale + # last_head_role reads the assembled (post-strip) head; first_tail_role + # reads the assembled (post-strip) tail_messages — a stripped stale # handoff must not influence alternation-safe role selection. + last_head_role = compressed[-1].get("role", "user") if compressed else "user" first_tail_role = tail_messages[0].get("role", "user") if tail_messages else None # When the only protected head message is the system prompt, the # summary becomes the first *visible* message in the API request @@ -4265,7 +4393,7 @@ This compaction should PRIORITISE preserving all information related to the focu # Anthropic unconditionally rejects requests whose first message # is not role=user, so we must pin the summary to "user" and # prevent the flip logic below from reverting it (#52160). - _force_user_leading = last_head_role == "system" + _force_user_leading = compress_start == 0 or last_head_role == "system" # Zero-user-turn guard (#58753). The #52160 guard above only fires # when the system prompt sits *inside* ``messages`` (the gateway # ``/compress`` path). The main auto-compression path passes the @@ -4299,7 +4427,7 @@ This compaction should PRIORITISE preserving all information related to the focu summary_role = "assistant" # If the chosen role collides with the tail AND flipping wouldn't # collide with the head, flip it. - if first_tail_role and summary_role == first_tail_role: + if first_tail_role is not None and summary_role == first_tail_role: flipped = "assistant" if summary_role == "user" else "user" if flipped != last_head_role and not _force_user_leading: summary_role = flipped @@ -4332,27 +4460,39 @@ This compaction should PRIORITISE preserving all information related to the focu for tail_idx, msg in enumerate(tail_messages): if _merge_summary_into_tail and tail_idx == 0: - # Merge the summary into the first tail message, but place - # the END MARKER at the very end so the model sees an - # unambiguous boundary. Old tail content is preserved as - # reference material BEFORE the summary, clearly delimited - # so it is not mistaken for a new message to respond to. - # Uses _append_text_to_content to safely handle both - # string and multimodal-list content types. - # Fixes ghost-message leakage across compaction boundaries - # where old head messages survived verbatim and appeared - # before the summary. + # Merge the summary into the first (post-strip) tail message. old_content = msg.get("content", "") - suffix = ( - "\n\n" + _MERGED_SUMMARY_DELIMITER + "\n\n" - + summary + "\n\n" - + _SUMMARY_END_MARKER - ) - msg["content"] = _append_text_to_content( - _append_text_to_content(old_content, suffix, prepend=False), - _MERGED_PRIOR_CONTEXT_HEADER + "\n", - prepend=True, - ) + if _force_user_leading and summary_role == "user": + # The summary must be part of the first user-visible + # message for Anthropic/Bedrock, but the real tail request + # still has to appear *after* the summary boundary. + prefix = summary + "\n\n" + _SUMMARY_END_MARKER + "\n\n" + msg["content"] = _append_text_to_content( + old_content, + prefix, + prepend=True, + ) + else: + # Merge the summary into the first tail message, but place + # the END MARKER at the very end so the model sees an + # unambiguous boundary. Old tail content is preserved as + # reference material BEFORE the summary, clearly delimited + # so it is not mistaken for a new message to respond to. + # Uses _append_text_to_content to safely handle both + # string and multimodal-list content types. + # Fixes ghost-message leakage across compaction boundaries + # where old head messages survived verbatim and appeared + # before the summary. + suffix = ( + "\n\n" + _MERGED_SUMMARY_DELIMITER + "\n\n" + + summary + "\n\n" + + _SUMMARY_END_MARKER + ) + msg["content"] = _append_text_to_content( + _append_text_to_content(old_content, suffix, prepend=False), + _MERGED_PRIOR_CONTEXT_HEADER + "\n", + prepend=True, + ) # Mark the merged message so frontends can identify it as # containing a compression summary prefix. msg[COMPRESSED_SUMMARY_METADATA_KEY] = True diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index e89d6725bd6f..dfe7e699379f 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -3898,6 +3898,35 @@ class TestDoubleCompactionSummaryRole: f"compatibility, got role={non_system[0]['role']!r}" ) + def test_restart_handoff_without_system_still_starts_with_user(self): + """When decayed head protection leaves no head, the visible transcript + must still begin with a user role for Anthropic/Bedrock compatibility. + """ + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "summary of resumed turns" + + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor( + model="test", quiet_mode=True, protect_first_n=3, protect_last_n=2, + ) + + msgs = [ + {"role": "user", "content": f"{SUMMARY_PREFIX}\nold persisted summary"}, + {"role": "assistant", "content": "handoff acknowledged"}, + {"role": "user", "content": "new work after restart"}, + {"role": "assistant", "content": "new answer after restart"}, + {"role": "user", "content": "more new work after restart"}, + {"role": "assistant", "content": "more new answer after restart"}, + {"role": "user", "content": "tail request"}, + {"role": "assistant", "content": "tail answer"}, + ] + with patch("agent.context_compressor.call_llm", return_value=mock_response): + result = c.compress(msgs) + + assert result[0]["role"] == "user" + assert "summary of resumed turns" in (result[0].get("content") or "") + def test_double_compaction_user_tail_merges_into_tail(self): """When the summary is forced to role=user (system-only head) and the first tail message is also user, the summary must merge into diff --git a/tests/agent/test_context_compressor_summary_continuity.py b/tests/agent/test_context_compressor_summary_continuity.py index 445a16a78e54..5f5f4a5c62f5 100644 --- a/tests/agent/test_context_compressor_summary_continuity.py +++ b/tests/agent/test_context_compressor_summary_continuity.py @@ -8,16 +8,17 @@ from agent.context_compressor import ( SUMMARY_PREFIX, _MERGED_PRIOR_CONTEXT_HEADER, _MERGED_SUMMARY_DELIMITER, + _RESTART_HANDOFF_PROBE_EXTRA_MESSAGES, _SUMMARY_END_MARKER, ) -def _compressor() -> ContextCompressor: +def _compressor(protect_first_n: int = 1) -> ContextCompressor: with patch("agent.context_compressor.get_model_context_length", return_value=100000): return ContextCompressor( model="test/model", threshold_percent=0.85, - protect_first_n=1, + protect_first_n=protect_first_n, protect_last_n=1, quiet_mode=True, ) @@ -58,6 +59,35 @@ def _messages_with_merged_handoff(summary_body: str, prior_tail: str): return messages +def _messages_with_default_handoff(summary_body: str): + return [ + {"role": "system", "content": "system prompt"}, + {"role": "user", "content": "original task before first compaction"}, + {"role": "assistant", "content": "original answer before first compaction"}, + {"role": "user", "content": "original follow-up before first compaction"}, + {"role": "assistant", "content": f"{SUMMARY_PREFIX}\n{summary_body}"}, + {"role": "user", "content": "new user turn after restart"}, + {"role": "assistant", "content": "new assistant work after restart"}, + {"role": "user", "content": "more new work after restart"}, + {"role": "assistant", "content": "latest tail response"}, + {"role": "user", "content": "final active request stays in protected tail"}, + ] + + +def _messages_with_summary_at_index(summary_index: int): + msgs = [{"role": "system", "content": "system prompt"}] + for idx in range(1, summary_index): + role = "user" if idx % 2 else "assistant" + msgs.append({"role": role, "content": f"probe filler {idx}"}) + role = "user" if summary_index % 2 else "assistant" + msgs.append({"role": role, "content": f"{SUMMARY_PREFIX}\nboundary summary"}) + msgs.extend([ + {"role": "assistant", "content": "new answer"}, + {"role": "user", "content": "tail request"}, + ]) + return msgs + + def test_existing_previous_summary_is_not_serialized_again_as_new_turn(): """Same-process iterative compression should not feed the old handoff twice.""" compressor = _compressor() @@ -258,3 +288,367 @@ def test_legacy_multimodal_merged_handoff_preserves_original_blocks(): "role": "user", "content": [prior_text, prior_image], } + + +def test_resume_handoff_in_protected_head_is_not_preserved_as_fossil(): + """After restart, a persisted handoff summary should decay head protection.""" + compressor = _compressor() + old_summary = "RESTART-FOSSIL-SUMMARY durable facts from before restart" + + with patch("agent.context_compressor.call_llm", return_value=_response("fresh summary")): + result = compressor.compress(_messages_with_handoff(old_summary)) + + assert compressor._previous_summary == "fresh summary" + summary_messages = [ + msg for msg in result + if ContextCompressor._has_compressed_summary_metadata(msg) + or ContextCompressor._is_context_summary_content(msg.get("content")) + ] + assert len(summary_messages) == 1 + assert all( + old_summary not in str(msg.get("content", "")) + for msg in result + ) + + +def test_resume_handoff_after_default_protected_head_decays_initial_turns(): + """Default protect_first_n=3 should not fossilize old protected head turns.""" + compressor = _compressor(protect_first_n=3) + old_summary = "DEFAULT-RESTART-SUMMARY durable facts from before restart" + + with patch("agent.context_compressor.call_llm", return_value=_response("fresh summary")) as mock_call: + result = compressor.compress(_messages_with_default_handoff(old_summary)) + + prompt = mock_call.call_args.kwargs["messages"][0]["content"] + assert "PREVIOUS SUMMARY:" in prompt + assert prompt.count(old_summary) == 1 + assert "original task before first compaction" in prompt + assert "original answer before first compaction" in prompt + assert "original follow-up before first compaction" in prompt + assert f"[ASSISTANT]: {SUMMARY_PREFIX}" not in prompt + assert compressor._previous_summary == "fresh summary" + assert all( + "original task before first compaction" not in str(msg.get("content", "")) + for msg in result + ) + assert all( + "original answer before first compaction" not in str(msg.get("content", "")) + for msg in result + ) + assert all( + old_summary not in str(msg.get("content", "")) + for msg in result + ) + + +def test_tail_summary_marker_does_not_decay_first_compaction_head(): + """A live tail summary-looking message should not mimic a resumed handoff.""" + compressor = _compressor(protect_first_n=3) + tail_summary = "TAIL-SUMMARY-LIKE message belongs to current protected tail" + msgs = [ + {"role": "system", "content": "system prompt"}, + {"role": "user", "content": "HEAD-ONE original request"}, + {"role": "assistant", "content": "HEAD-TWO original answer"}, + {"role": "user", "content": "HEAD-THREE original follow-up"}, + {"role": "assistant", "content": "middle answer one"}, + {"role": "user", "content": "middle request two"}, + {"role": "assistant", "content": "middle answer two"}, + {"role": "user", "content": "middle request three"}, + {"role": "assistant", "content": "middle answer three"}, + {"role": "user", "content": "middle request four"}, + {"role": "assistant", "content": f"{SUMMARY_PREFIX}\n{tail_summary}"}, + {"role": "user", "content": "final active request stays in protected tail"}, + ] + + with patch("agent.context_compressor.call_llm", return_value=_response("fresh summary")): + result = compressor.compress(msgs) + + result_text = "\n".join(str(msg.get("content", "")) for msg in result) + assert "HEAD-ONE original request" in result_text + assert "HEAD-TWO original answer" in result_text + assert "HEAD-THREE original follow-up" in result_text + assert tail_summary not in result_text + + +def test_restart_handoff_in_protected_tail_is_folded_not_preserved(): + """Short resumed transcripts should not copy old summaries as tail.""" + compressor = _compressor(protect_first_n=3) + old_summary = "TAIL-PROTECTED-OLD-SUMMARY durable facts" + + msgs = [ + {"role": "system", "content": "system prompt"}, + {"role": "user", "content": "original task"}, + {"role": "assistant", "content": "original answer"}, + {"role": "user", "content": "original follow-up"}, + {"role": "assistant", "content": f"{SUMMARY_PREFIX}\n{old_summary}"}, + {"role": "user", "content": "active request"}, + ] + + with patch("agent.context_compressor.call_llm", return_value=_response("fresh summary")) as mock_call: + result = compressor.compress(msgs) + + prompt = mock_call.call_args.kwargs["messages"][0]["content"] + assert "PREVIOUS SUMMARY:" in prompt + assert prompt.count(old_summary) == 1 + result_text = "\n".join(str(msg.get("content", "")) for msg in result) + assert old_summary not in result_text + assert "active request" in result_text + assert sum( + 1 for msg in result if ContextCompressor._is_context_summary_message(msg) + ) == 1 + + +def test_restart_handoff_fallback_preserves_rehydrated_summary_body(): + """Deterministic fallback should retain the rehydrated old summary.""" + compressor = _compressor(protect_first_n=3) + old_summary = "FALLBACK-OLD-SUMMARY durable fact must survive" + + with patch.object(compressor, "_generate_summary", return_value=None): + result = compressor.compress(_messages_with_default_handoff(old_summary)) + + result_text = "\n".join(str(msg.get("content", "")) for msg in result) + assert result_text.count(old_summary) == 1 + assert sum( + 1 for msg in result if ContextCompressor._is_context_summary_message(msg) + ) == 1 + + +def test_zero_protect_first_n_still_folds_restart_fossil(): + """protect_first_n=0 should still self-heal restarted summaries.""" + compressor = _compressor(protect_first_n=0) + old_summary = "OLD-SUMMARY-ZERO-PROTECT durable facts" + msgs = [ + {"role": "system", "content": "system prompt"}, + {"role": "user", "content": "task one"}, + {"role": "assistant", "content": "answer one"}, + {"role": "user", "content": "task two"}, + {"role": "assistant", "content": f"{SUMMARY_PREFIX}\n{old_summary}"}, + {"role": "user", "content": "active request"}, + ] + + with patch("agent.context_compressor.call_llm", return_value=_response("fresh summary")): + result = compressor.compress(msgs) + + result_text = "\n".join(str(msg.get("content", "")) for msg in result) + assert old_summary not in result_text + assert result_text.index(_SUMMARY_END_MARKER) < result_text.index("active request") + assert sum( + 1 for msg in result if ContextCompressor._is_context_summary_message(msg) + ) == 1 + + +def test_fossil_beyond_restart_probe_window_is_still_folded(): + """Self-heal should find summaries that drift past the decay probe.""" + compressor = _compressor(protect_first_n=1) + old_summary = "OLD-SUMMARY-FAR-FROM-HEAD durable facts" + msgs = [{"role": "system", "content": "system prompt"}] + msgs += [ + { + "role": "user" if idx % 2 else "assistant", + "content": f"filler {idx}", + } + for idx in range(1, 6) + ] + msgs += [ + {"role": "assistant", "content": f"{SUMMARY_PREFIX}\n{old_summary}"}, + {"role": "user", "content": "active request"}, + ] + + assert compressor._effective_protect_first_n(msgs) == compressor.protect_first_n + + with patch("agent.context_compressor.call_llm", return_value=_response("fresh summary")): + result = compressor.compress(msgs) + + assert all(old_summary not in str(msg.get("content", "")) for msg in result) + + +def test_restart_fossil_survives_summary_abort_then_retry(): + """An aborted first compaction must not strand the rehydrated fossil. + + Regression for the abort/retry path. The first-compaction self-heal scan + (``compression_count < 1``) populates ``_previous_summary`` from a fossil + that drifted past the decay probe. If summary generation then aborts + (auth / network / ``abort_on_summary_failure``) and returns the transcript + unchanged, the aborted attempt must not leave that rehydrated state behind: + otherwise the retry — still ``compression_count == 0`` but now with a + truthy ``_previous_summary`` — takes the narrow rescan, misses the + beyond-window fossil, and then discards the rehydrated summary as + cross-session leakage, copying the fossil forward as a stacked summary. + """ + compressor = _compressor(protect_first_n=1) + compressor.abort_on_summary_failure = True + old_summary = "ABORT-RETRY-OLD-SUMMARY durable facts" + msgs = [{"role": "system", "content": "system prompt"}] + msgs += [ + { + "role": "user" if idx % 2 else "assistant", + "content": f"filler {idx}", + } + for idx in range(1, 6) + ] + msgs += [ + {"role": "assistant", "content": f"{SUMMARY_PREFIX}\n{old_summary}"}, + {"role": "user", "content": "active request"}, + ] + + # First compaction aborts on a summary-generation failure. The transcript + # is returned unchanged AND the self-heal state it rehydrated must be + # rolled back, so a retry behaves like the original first compaction. + with patch.object(compressor, "_generate_summary", return_value=None): + aborted = compressor.compress([dict(m) for m in msgs]) + assert compressor._last_compress_aborted is True + assert all(m["content"] for m in aborted) # returned unchanged + assert compressor.compression_count == 0 + assert compressor._previous_summary is None + + # Retry: the fossil beyond the narrow window is still folded, not copied + # forward as a second stacked summary. + with patch("agent.context_compressor.call_llm", return_value=_response("fresh summary")): + result = compressor.compress([dict(m) for m in msgs]) + + assert all(old_summary not in str(msg.get("content", "")) for msg in result) + assert sum( + 1 for msg in result if ContextCompressor._is_context_summary_message(msg) + ) == 1 + + +def test_tail_turns_before_late_handoff_are_not_lost(): + """Live tail turns before a late handoff should be summarized or kept.""" + compressor = _compressor(protect_first_n=3) + old_summary = "LATE-TAIL-OLD-SUMMARY" + msgs = [{"role": "system", "content": "system prompt"}] + msgs += [ + { + "role": "user" if idx % 2 else "assistant", + "content": f"body {idx}", + } + for idx in range(1, 9) + ] + msgs += [ + {"role": "assistant", "content": "TAIL-BEFORE-SUMMARY-A"}, + {"role": "user", "content": "TAIL-BEFORE-SUMMARY-B"}, + {"role": "assistant", "content": f"{SUMMARY_PREFIX}\n{old_summary}"}, + {"role": "user", "content": "final active request"}, + ] + + with patch("agent.context_compressor.call_llm", return_value=_response("fresh summary")) as mock_call: + result = compressor.compress(msgs) + + preserved = mock_call.call_args.kwargs["messages"][0]["content"] + "\n" + "\n".join( + str(msg.get("content", "")) for msg in result + ) + assert "TAIL-BEFORE-SUMMARY-A" in preserved + assert "TAIL-BEFORE-SUMMARY-B" in preserved + + +def test_forced_leading_merged_summary_strips_live_tail_from_summary_body(): + """Rehydrating a forced-leading merged summary should ignore live tail.""" + merged = ( + f"{SUMMARY_PREFIX}\nSUMMARY_BODY\n\n" + f"{_SUMMARY_END_MARKER}\n\n" + "LIVE_TAIL_REQUEST" + ) + + assert ContextCompressor._is_context_summary_content(merged) is True + assert ContextCompressor._strip_summary_prefix(merged) == "SUMMARY_BODY" + + +def test_restart_probe_boundary_summary_just_inside_window_decays(): + """A summary at the last restart-probe index should still decay.""" + compressor = _compressor(protect_first_n=3) + first_non_system = 1 + last_probe_idx = ( + first_non_system + + compressor.protect_first_n + + _RESTART_HANDOFF_PROBE_EXTRA_MESSAGES + - 1 + ) + + assert ( + compressor._effective_protect_first_n( + _messages_with_summary_at_index(last_probe_idx) + ) + == 0 + ) + + +def test_restart_probe_boundary_summary_just_outside_window_does_not_decay(): + """A summary past the restart-probe window should not decay.""" + compressor = _compressor(protect_first_n=3) + first_non_system = 1 + first_outside_probe_idx = ( + first_non_system + + compressor.protect_first_n + + _RESTART_HANDOFF_PROBE_EXTRA_MESSAGES + ) + + assert ( + compressor._effective_protect_first_n( + _messages_with_summary_at_index(first_outside_probe_idx) + ) + == compressor.protect_first_n + ) + + +def test_restart_stacked_handoffs_fold_stray_head_and_collapse_to_single_summary(): + """Stacked restart summaries should keep stray head turns as new input.""" + compressor = _compressor(protect_first_n=3) + old_summary = "OLD-ONLY facts from the first compaction" + newer_summary = "NEW-ONLY facts from work after restart" + + msgs = [ + {"role": "system", "content": "system prompt"}, + {"role": "user", "content": "FOSSIL-HEAD-TURN live detail before summary"}, + {"role": "assistant", "content": f"{SUMMARY_PREFIX}\n{old_summary}"}, + {"role": "user", "content": f"{SUMMARY_PREFIX}\n{newer_summary}"}, + {"role": "assistant", "content": "work after restart"}, + {"role": "user", "content": "more work after restart"}, + {"role": "assistant", "content": "tail answer"}, + {"role": "user", "content": "active tail request"}, + ] + + with patch("agent.context_compressor.call_llm", return_value=_response("fresh summary")) as mock_call: + result = compressor.compress(msgs) + + prompt = mock_call.call_args.kwargs["messages"][0]["content"] + assert "PREVIOUS SUMMARY:" in prompt + assert prompt.count(old_summary) == 1 + assert prompt.count(newer_summary) == 1 + assert "FOSSIL-HEAD-TURN live detail before summary" in prompt + assert f"[ASSISTANT]: {SUMMARY_PREFIX}" not in prompt + assert f"[USER]: {SUMMARY_PREFIX}" not in prompt + summary_messages = [ + msg for msg in result + if ContextCompressor._is_context_summary_message(msg) + ] + assert len(summary_messages) == 1 + assert all(old_summary not in str(msg.get("content", "")) for msg in result) + assert all(newer_summary not in str(msg.get("content", "")) for msg in result) + assert all("FOSSIL-HEAD-TURN" not in str(msg.get("content", "")) for msg in result) + + +def test_metadata_summary_decay_also_rehydrates_previous_summary(): + """Metadata-only in-process summaries should decay and rehydrate together.""" + compressor = _compressor(protect_first_n=3) + + msgs = [ + {"role": "system", "content": "system prompt"}, + { + "role": "assistant", + "content": "metadata-only prior summary", + COMPRESSED_SUMMARY_METADATA_KEY: True, + }, + {"role": "user", "content": "new work"}, + {"role": "assistant", "content": "new answer"}, + {"role": "user", "content": "tail request"}, + {"role": "assistant", "content": "tail answer"}, + ] + + with patch("agent.context_compressor.call_llm", return_value=_response("fresh summary")) as mock_call: + compressor.compress(msgs) + + prompt = mock_call.call_args.kwargs["messages"][0]["content"] + assert "PREVIOUS SUMMARY:" in prompt + assert "metadata-only prior summary" in prompt + assert compressor._previous_summary == "fresh summary" + From a4aedde3ae21a4a7fc578d26454bbcfd2c3a1a7d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 22 Jul 2026 05:54:18 -0700 Subject: [PATCH 005/238] test(compression): adapt salvaged pins to task-snapshot grounding + add restart-simulation test - Three '_previous_summary == "fresh summary"' exact pins and one transcript-wide fossil-absence pin predated main's deterministic task-snapshot grounding (761a0b124e), which prepends a '## Historical Task Snapshot' section to stored summaries and may quote a folded head turn inside the handoff. Re-pin the contracts (fresh body present, fossil absent from non-summary messages) instead of exact strings. - Add test_restart_simulation_fresh_compressor_does_not_reprotect_head: a fresh ContextCompressor over a transcript containing a persisted handoff summary computes the same decayed protected-head boundary (compress_start base) as a live already-compacted process, and the first post-restart compaction does not preserve pre-restart head fossils (#57814). --- ...t_context_compressor_summary_continuity.py | 66 +++++++++++++++++-- 1 file changed, 61 insertions(+), 5 deletions(-) diff --git a/tests/agent/test_context_compressor_summary_continuity.py b/tests/agent/test_context_compressor_summary_continuity.py index 5f5f4a5c62f5..e5be05d55c3e 100644 --- a/tests/agent/test_context_compressor_summary_continuity.py +++ b/tests/agent/test_context_compressor_summary_continuity.py @@ -298,7 +298,12 @@ def test_resume_handoff_in_protected_head_is_not_preserved_as_fossil(): with patch("agent.context_compressor.call_llm", return_value=_response("fresh summary")): result = compressor.compress(_messages_with_handoff(old_summary)) - assert compressor._previous_summary == "fresh summary" + # Main's task-snapshot grounding (761a0b124e) prepends a deterministic + # "## Historical Task Snapshot" section to the stored summary — pin the + # contract (fresh body present, fossil absent), not the exact string. + stored_summary = compressor._previous_summary or "" + assert stored_summary.endswith("fresh summary") + assert old_summary not in stored_summary summary_messages = [ msg for msg in result if ContextCompressor._has_compressed_summary_metadata(msg) @@ -326,7 +331,11 @@ def test_resume_handoff_after_default_protected_head_decays_initial_turns(): assert "original answer before first compaction" in prompt assert "original follow-up before first compaction" in prompt assert f"[ASSISTANT]: {SUMMARY_PREFIX}" not in prompt - assert compressor._previous_summary == "fresh summary" + # Grounding (761a0b124e) may prepend a deterministic task-snapshot + # section — pin the contract, not the exact stored string. + stored_summary = compressor._previous_summary or "" + assert stored_summary.endswith("fresh summary") + assert old_summary not in stored_summary assert all( "original task before first compaction" not in str(msg.get("content", "")) for msg in result @@ -341,6 +350,44 @@ def test_resume_handoff_after_default_protected_head_decays_initial_turns(): ) +def test_restart_simulation_fresh_compressor_does_not_reprotect_head(): + """Gateway-restart simulation: a FRESH ContextCompressor (in-memory decay + state reset — compression_count == 0, _previous_summary is None) over a + transcript that contains a persisted handoff summary must NOT re-protect + the head. compress_start must reflect decayed protection exactly as a + live (non-restarted) process would compute it (#57814).""" + # Live process: has already compacted once, decay is in-memory. + live = _compressor(protect_first_n=3) + live.compression_count = 1 + + # Restarted process: brand-new compressor, all in-memory state fresh. + restarted = _compressor(protect_first_n=3) + assert restarted.compression_count == 0 + assert not restarted._previous_summary + + msgs = _messages_with_default_handoff( + "PERSISTED-HANDOFF durable facts from before restart" + ) + + # The protected-head boundary the compressor uses for compress_start + # must be identical for both: system prompt only (decayed protection). + assert restarted._effective_protect_first_n(msgs) == 0 + assert restarted._protect_head_size(msgs) == live._protect_head_size(msgs) == 1 + restarted_start = restarted._align_boundary_forward( + msgs, restarted._protect_head_size(msgs) + ) + assert restarted_start == 1 + + # End-to-end: the first post-restart compaction must not preserve the + # pre-restart head turns or the old handoff verbatim. + with patch("agent.context_compressor.call_llm", return_value=_response("fresh summary")): + result = restarted.compress(msgs) + result_text = "\n".join(str(msg.get("content", "")) for msg in result) + assert "PERSISTED-HANDOFF durable facts" not in result_text + assert "original task before first compaction" not in result_text + assert "original answer before first compaction" not in result_text + + def test_tail_summary_marker_does_not_decay_first_compaction_head(): """A live tail summary-looking message should not mimic a resumed handoff.""" compressor = _compressor(protect_first_n=3) @@ -624,7 +671,16 @@ def test_restart_stacked_handoffs_fold_stray_head_and_collapse_to_single_summary assert len(summary_messages) == 1 assert all(old_summary not in str(msg.get("content", "")) for msg in result) assert all(newer_summary not in str(msg.get("content", "")) for msg in result) - assert all("FOSSIL-HEAD-TURN" not in str(msg.get("content", "")) for msg in result) + # The stray head turn must be folded into the summary, not preserved as + # its own verbatim message. Main's task-snapshot grounding (761a0b124e) + # may legitimately QUOTE it inside the summary handoff as the + # deterministic "User asked" anchor, so only non-summary messages are + # checked for the verbatim fossil. + assert all( + "FOSSIL-HEAD-TURN" not in str(msg.get("content", "")) + for msg in result + if not ContextCompressor._is_context_summary_message(msg) + ) def test_metadata_summary_decay_also_rehydrates_previous_summary(): @@ -650,5 +706,5 @@ def test_metadata_summary_decay_also_rehydrates_previous_summary(): prompt = mock_call.call_args.kwargs["messages"][0]["content"] assert "PREVIOUS SUMMARY:" in prompt assert "metadata-only prior summary" in prompt - assert compressor._previous_summary == "fresh summary" - + # Grounding may prepend a task-snapshot section; pin the fresh body. + assert (compressor._previous_summary or "").endswith("fresh summary") From 76e17bc32d988be30713edfe0037b2ca61402f9f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:58:54 -0700 Subject: [PATCH 006/238] fix(compression): recover merged-handoff prior-tail content through the decay scan Composing #57835's multi-fossil summary scan with #47274's merged-handoff unwrap: when the restart-decay path pulls a merged handoff into the compression window, its genuine prior-tail user content must enter the summarizer input (folded into the fresh summary) rather than being dropped with the summary row. Standalone handoffs still drop. The continuity test now pins the composed contract: recovered verbatim OR via summarizer input, never silently deleted, never duplicated. --- agent/context_compressor.py | 27 +++++++++++++++-- ...t_context_compressor_summary_continuity.py | 29 +++++++++++++++---- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 45ede22c9b54..d249f9898b69 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -4193,16 +4193,39 @@ This compaction should PRIORITISE preserving all information related to the focu summary_body and _NO_USER_TASK_SENTINEL in summary_body ) summary_indices = {idx for idx, _ in summary_hits} + # Summary rows are excluded from the summarizer input (their + # bodies already ride _previous_summary), BUT a merged handoff + # carries genuine prior-tail user content before the delimiter — + # unwrap it (via the strip helper) into the window instead of + # dropping it (#47274 interplay with the multi-fossil scan). + def _window_row(idx: int, msg: Dict[str, Any]): + if idx not in summary_indices: + return msg + stripped = self._strip_context_summary_handoff_message( + _fresh_compaction_message_copy(msg) + ) + return stripped # None for standalone handoffs → dropped pre_summary_turns = [ - msg for idx, msg in enumerate( + row for idx, msg in enumerate( messages[compress_start:summary_idx], start=compress_start, ) - if idx not in summary_indices + if (row := _window_row(idx, msg)) is not None ] turns_to_summarize = ( pre_summary_turns + messages[summary_idx + 1:compress_end] ) + # The newest hit itself may be a merged handoff too — recover its + # prior-tail content the same way. + _newest_stripped = self._strip_context_summary_handoff_message( + _fresh_compaction_message_copy(messages[summary_idx]) + ) + if _newest_stripped is not None: + turns_to_summarize = ( + pre_summary_turns + + [_newest_stripped] + + messages[summary_idx + 1:compress_end] + ) if summary_idx >= compress_end: tail_start = summary_idx + 1 elif self._previous_summary: diff --git a/tests/agent/test_context_compressor_summary_continuity.py b/tests/agent/test_context_compressor_summary_continuity.py index e5be05d55c3e..18b4c76b6256 100644 --- a/tests/agent/test_context_compressor_summary_continuity.py +++ b/tests/agent/test_context_compressor_summary_continuity.py @@ -203,25 +203,42 @@ def test_legacy_string_merged_handoff_preserves_real_tail_text(): def test_recompression_of_current_merged_handoff_preserves_prior_tail_once(): - """Current merged handoffs lose only stale summary data on recompression.""" + """Current merged handoffs lose only stale summary data on recompression. + + Composed contract after #57835 (restart head-protection decay): the + merged handoff's genuine prior-tail content must be RECOVERED — either + verbatim in the output (pre-decay head protection) or by entering the + summarizer input so the fresh summary folds it in (post-decay). It must + never be silently deleted, and the stale summary body must never be + re-emitted verbatim. + """ compressor = _compressor() old_summary = "CURRENT-MERGED-OLD-SUMMARY unique continuity facts" prior_tail = "PRESERVED-PRIOR-TAIL real user content" + seen_turns = [] + + def _capture(turns, **kwargs): + seen_turns.extend(turns) + return ContextCompressor._with_summary_prefix( + "fresh replacement summary" + ) + with patch.object( compressor, "_generate_summary", - return_value=ContextCompressor._with_summary_prefix( - "fresh replacement summary" - ), + side_effect=_capture, ): result = compressor.compress( _messages_with_merged_handoff(old_summary, prior_tail) ) joined = "\n".join(str(message.get("content", "")) for message in result) - assert prior_tail in joined - assert joined.count(prior_tail) == 1 + summarizer_input = "\n".join(str(t.get("content", "")) for t in seen_turns) + # Prior tail recovered: verbatim in output OR folded via summarizer input. + assert prior_tail in joined or prior_tail in summarizer_input + # Never duplicated in the output. + assert joined.count(prior_tail) <= 1 assert old_summary not in joined assert joined.count(SUMMARY_PREFIX) == 1 assert "fresh replacement summary" in joined From 72056faf8f4258814ed99097a989b50ecc6fc219 Mon Sep 17 00:00:00 2001 From: Muthuvel <8766057+iso2kx@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:48:46 +0800 Subject: [PATCH 007/238] feat(compression): add opt-in idle-triggered context compaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long-lived sessions (e.g. a Telegram thread resumed over hours/days) accumulate a large context that the existing size-based threshold only trims once it crosses `threshold × context_window`. Until then every turn re-reads the full history, which on large-context models can mean hundreds of K of cache-read tokens per call even across long idle gaps. Add a time-based trigger that complements (does not replace) the size threshold: when a session resumes after `compression.idle_compact_after_seconds` of inactivity, compact the accumulated history up front, before the first reply. Disabled by default (0), so existing behaviour is unchanged. The trigger reuses `_last_activity_ts` (the last time the turn loop did work) to measure the idle gap at turn start, gates the token estimate behind a cheap gap pre-check, and skips compaction when the context is already at/below the post-compression target (threshold × target_ratio) so a short idle thread never pays for a summarization that saves nothing. It also defers to an active compression-failure cooldown. The decision is factored into a pure predicate, `_should_idle_compact`, which is unit-tested without a live agent. --- agent/agent_init.py | 9 +++ agent/turn_context.py | 91 +++++++++++++++++++++++++++++ cli-config.yaml.example | 10 ++++ tests/agent/test_idle_compaction.py | 55 +++++++++++++++++ 4 files changed, 165 insertions(+) create mode 100644 tests/agent/test_idle_compaction.py diff --git a/agent/agent_init.py b/agent/agent_init.py index c268c37d505c..2094dd2b4f7f 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1869,6 +1869,12 @@ def init_agent( codex_app_server_auto_compaction, ) codex_app_server_auto_compaction = "native" + # Opt-in idle compaction: compact a session up front when it resumes after + # this many seconds of inactivity (0 = disabled). Time-based, so it + # complements the size-based threshold above. Consumed by build_turn_context(). + compression_idle_compact_after_seconds = max( + 0, int(_compression_cfg.get("idle_compact_after_seconds", 0)) + ) # Read optional explicit context_length override for the auxiliary # compression model. Custom endpoints often cannot report this via @@ -2295,6 +2301,9 @@ def init_agent( agent.compression_in_place = compression_in_place agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction agent.max_compression_attempts = compression_max_attempts + agent.compression_idle_compact_after_seconds = ( + compression_idle_compact_after_seconds + ) # Reject models whose context window is below the minimum required # for reliable tool-calling workflows (64K tokens). diff --git a/agent/turn_context.py b/agent/turn_context.py index 669d615847cf..045ebd865b3f 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -26,6 +26,7 @@ from __future__ import annotations import logging import threading +import time import uuid from dataclasses import dataclass from typing import Any, Dict, List, Mapping, Optional @@ -255,6 +256,40 @@ def _should_run_preflight_estimate( return estimate_messages_tokens_rough(messages) >= threshold_tokens +def _should_idle_compact( + *, + enabled: bool, + idle_after_seconds: int, + idle_gap_seconds: float, + tokens: int, + floor_tokens: int, + cooldown_active: bool, +) -> bool: + """Decide whether an idle-triggered compaction should run this turn. + + Idle compaction is opt-in (``idle_after_seconds <= 0`` disables it). It + fires when a session resumes after a wall-clock gap of at least + ``idle_after_seconds`` since its last activity, so a long-lived thread + that is paused and later resumed compacts its accumulated history up + front instead of re-reading it on every subsequent turn. + + It is orthogonal to the token-threshold trigger: it does NOT require the + context to exceed ``threshold_tokens``. It still skips work when the + context is at or below ``floor_tokens`` (the size compaction would reduce + *to*), so a small idle thread never pays for a summarisation that saves + nothing, and it defers to an active compression-failure cooldown. + + Pure predicate so the policy is unit-testable without a live agent. + """ + if not enabled or idle_after_seconds <= 0: + return False + if idle_gap_seconds < idle_after_seconds: + return False + if cooldown_active: + return False + return tokens > floor_tokens + + @dataclass class TurnContext: """Values produced by the turn prologue and consumed by the turn loop.""" @@ -579,6 +614,62 @@ def build_turn_context( if not isinstance(pending_cli_message, dict) or pending_cli_message.get("_db_persisted"): agent._pending_cli_user_message = None + # ── Idle-triggered compaction (opt-in; ``idle_compact_after_seconds``) ── + # When a session resumes after a long idle gap, compact the accumulated + # history up front so the rest of the conversation does not keep re-reading + # a large stale context on every turn. This fires on elapsed wall-clock time + # rather than size, so it complements (does not replace) the token-threshold + # preflight below. ``_last_activity_ts`` is the last time this turn loop did + # work; nothing has touched it yet this turn, so it measures the gap since + # the previous turn finished. The cheap gap pre-check gates the (more + # expensive) token estimate, mirroring ``_should_run_preflight_estimate``. + _idle_after = getattr(agent, "compression_idle_compact_after_seconds", 0) + if agent.compression_enabled and _idle_after > 0 and messages: + _idle_gap = time.time() - getattr(agent, "_last_activity_ts", time.time()) + if _idle_gap >= _idle_after: + _compressor = agent.context_compressor + _idle_tokens = estimate_request_tokens_rough( + messages, + system_prompt=active_system_prompt or "", + tools=agent.tools or None, + ) + # Post-compression target size: don't summarise a thread already + # below what compaction would reduce it to. + _idle_floor = int( + _compressor.threshold_tokens * _compressor.summary_target_ratio + ) + _idle_cooldown = getattr( + _compressor, "get_active_compression_failure_cooldown", lambda: None + )() + if _should_idle_compact( + enabled=agent.compression_enabled, + idle_after_seconds=_idle_after, + idle_gap_seconds=_idle_gap, + tokens=_idle_tokens, + floor_tokens=_idle_floor, + cooldown_active=bool(_idle_cooldown), + ): + logger.info( + "Idle compaction: %ss idle >= %ss, ~%s tokens > %s floor " + "(session %s)", + int(_idle_gap), + _idle_after, + f"{_idle_tokens:,}", + f"{_idle_floor:,}", + agent.session_id or "none", + ) + agent._emit_status( + f"💤 Resumed after {int(_idle_gap)}s idle — compacting " + f"~{_idle_tokens:,} tokens before continuing." + ) + messages, active_system_prompt = agent._compress_context( + messages, system_message, approx_tokens=_idle_tokens, + task_id=effective_task_id, + ) + conversation_history = conversation_history_after_compression( + agent, messages + ) + # ── Preflight context compression ── # Gate the (expensive) full token estimate behind a cheap pre-check. # See ``_should_run_preflight_estimate`` for the OR semantics that fix diff --git a/cli-config.yaml.example b/cli-config.yaml.example index c93d4db15a36..c3990962f063 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -474,6 +474,16 @@ compression: # head messages, matching the pre-feature behaviour. protect_first_n: 3 + # Idle compaction (default: 0 = disabled). When > 0, a session that resumes + # after at least this many seconds of inactivity compacts its accumulated + # history up front, before the first reply, so a long-lived thread you come + # back to later doesn't re-read its full stale context on every turn. + # Time-based, so it complements (does not replace) the size-based `threshold` + # above. It is skipped when the context is already small (at or below the + # post-compression target = threshold × target_ratio), so it never wastes a + # summarization on a short idle thread. Example: 1800 = compact after 30 min idle. + idle_compact_after_seconds: 0 + # To pin a specific model/provider for compression summaries, use the # auxiliary section below (auxiliary.compression.provider / model). diff --git a/tests/agent/test_idle_compaction.py b/tests/agent/test_idle_compaction.py new file mode 100644 index 000000000000..103a41b3405b --- /dev/null +++ b/tests/agent/test_idle_compaction.py @@ -0,0 +1,55 @@ +"""Tests for the opt-in idle-triggered compaction policy. + +Covers ``agent.turn_context._should_idle_compact`` — the pure predicate that +decides whether a session resuming after an idle gap should compact up front. +The predicate is intentionally side-effect-free so the policy can be verified +without constructing a live agent or DB. +""" + +from agent.turn_context import _should_idle_compact + + +def _decide(**overrides): + """Call the predicate with sensible defaults (idle + large context => fire).""" + kwargs = dict( + enabled=True, + idle_after_seconds=1800, + idle_gap_seconds=3600.0, + tokens=100_000, + floor_tokens=40_000, + cooldown_active=False, + ) + kwargs.update(overrides) + return _should_idle_compact(**kwargs) + + +class TestShouldIdleCompact: + def test_fires_when_idle_long_enough_and_context_large(self): + assert _decide() is True + + def test_disabled_when_idle_after_zero(self): + # 0 is the documented "off" value — must never fire regardless of gap. + assert _decide(idle_after_seconds=0, idle_gap_seconds=10_000.0) is False + + def test_disabled_when_idle_after_negative(self): + assert _decide(idle_after_seconds=-1) is False + + def test_disabled_when_compression_off(self): + assert _decide(enabled=False) is False + + def test_skips_when_gap_below_threshold(self): + assert _decide(idle_gap_seconds=600.0) is False + + def test_gap_exactly_at_threshold_fires(self): + assert _decide(idle_after_seconds=1800, idle_gap_seconds=1800.0) is True + + def test_skips_when_context_at_or_below_floor(self): + # At/below the post-compression target there is nothing worth saving. + assert _decide(tokens=40_000, floor_tokens=40_000) is False + assert _decide(tokens=39_999, floor_tokens=40_000) is False + + def test_fires_just_above_floor(self): + assert _decide(tokens=40_001, floor_tokens=40_000) is True + + def test_defers_to_active_compression_cooldown(self): + assert _decide(cooldown_active=True) is False From 1efe7094ad24cad0cefa1c0ab38705e4811ad73a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 22 Jul 2026 06:24:41 -0700 Subject: [PATCH 008/238] =?UTF-8?q?feat(compression):=20harden=20idle=20co?= =?UTF-8?q?mpaction=20=E2=80=94=20lock/guard=20interplay,=20config=20+=20d?= =?UTF-8?q?ocs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up for the salvaged #55800 idle-compaction commit: - turn_context.py: treat a skipped _compress_context (per-session compression lock held by another path, failure cooldown, anti-thrash breaker, codex-native routing) as a strict no-op — only re-baseline conversation_history and re-anchor current_turn_user_idx after a REAL compaction. Also re-anchor the user-message index after idle compaction (the PR predates the reanchor helper). - hermes_cli/config.py: add idle_compact_after_seconds: 0 to DEFAULT_CONFIG's compression block (the PR only had cli-config.yaml.example). - gateway/run.py: add the idle-compaction status wording to _TELEGRAM_NOISY_STATUS_RE so the new 💤 message stays out of human-facing chat surfaces (routine compaction is silent by design); pin it in tests/gateway/test_telegram_noise_filter.py. - docs: idle_compact_after_seconds in user-guide/configuration.md and developer-guide/context-compression-and-caching.md parameter table. - tests/agent/test_idle_compaction_lock_and_guards.py: end-to-end coverage with a real AIAgent + SessionDB proving the idle path honors the per-session compression lock (added after the PR), the persisted failure cooldown, and the anti-thrash breaker, and that the lock is released after an idle-triggered compaction. Salvaged from #55800 by @iso2kx. Implements #27579. --- agent/turn_context.py | 21 +- gateway/run.py | 1 + hermes_cli/config.py | 15 ++ .../test_idle_compaction_lock_and_guards.py | 241 ++++++++++++++++++ tests/gateway/test_telegram_noise_filter.py | 1 + .../context-compression-and-caching.md | 1 + website/docs/user-guide/configuration.md | 3 + 7 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 tests/agent/test_idle_compaction_lock_and_guards.py diff --git a/agent/turn_context.py b/agent/turn_context.py index 045ebd865b3f..11f402ba488e 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -662,13 +662,28 @@ def build_turn_context( f"💤 Resumed after {int(_idle_gap)}s idle — compacting " f"~{_idle_tokens:,} tokens before continuing." ) + _idle_input = messages messages, active_system_prompt = agent._compress_context( messages, system_message, approx_tokens=_idle_tokens, task_id=effective_task_id, ) - conversation_history = conversation_history_after_compression( - agent, messages - ) + # ``_compress_context`` returns the INPUT list object when it + # skips (per-session lock held by another path, failure + # cooldown, anti-thrash breaker, codex-native routing). Only + # re-baseline + re-anchor after a real compaction — a skip + # must leave the turn's flush baseline and user-message index + # untouched. + if messages is not _idle_input: + conversation_history = conversation_history_after_compression( + agent, messages + ) + # Compaction rebuilt the list, so the index of this turn's + # just-appended user message is stale — re-anchor it the + # same way the preflight path does below. + current_turn_user_idx = reanchor_current_turn_user_idx( + messages, user_message + ) + agent._persist_user_message_idx = current_turn_user_idx # ── Preflight context compression ── # Gate the (expensive) full token estimate behind a cheap pre-check. diff --git a/gateway/run.py b/gateway/run.py index 0b694629c19f..42dd61e02776 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -80,6 +80,7 @@ _TELEGRAM_NOISY_STATUS_RE = re.compile( r"|no\s+auxiliary\s+llm\s+provider\s+configured" r"|auto-lowered\s+compression\s+threshold" r"|compacting\s+context\s+[—-]\s+summarizing\s+earlier\s+conversation" + r"|resumed\s+after\s+\d+s\s+idle\s+[—-]\s+compacting" r"|preflight\s+compression" r"|session\s+compressed\s+\d+\s+times" r"|rate\s+limited\.\s+waiting\s+\d" diff --git a/hermes_cli/config.py b/hermes_cli/config.py index bbb6a747ab0e..93dd86fad599 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1549,6 +1549,21 @@ DEFAULT_CONFIG = { # models) still applies on top of overrides # (raise-only: an override above the floor # wins; one below it is raised to the floor). + "idle_compact_after_seconds": 0, # Opt-in idle compaction (0 = disabled). + # When > 0, a session that resumes after at + # least this many seconds of inactivity + # compacts its accumulated history up front, + # before the first reply — so a long-lived + # thread resumed hours later doesn't re-read + # its full stale context on every turn. + # Time-based; complements (does not replace) + # the size-based `threshold` above. Skipped + # when the context is already at/below the + # post-compression target (threshold × + # target_ratio) and it honors the same + # failure-cooldown / anti-thrash / per-session + # lock guards as every automatic compaction. + # Example: 1800 = compact after 30 min idle. }, # Kanban subsystem (orchestrator workers + dispatcher-driven child tasks). diff --git a/tests/agent/test_idle_compaction_lock_and_guards.py b/tests/agent/test_idle_compaction_lock_and_guards.py new file mode 100644 index 000000000000..39bb98f54c5e --- /dev/null +++ b/tests/agent/test_idle_compaction_lock_and_guards.py @@ -0,0 +1,241 @@ +"""Idle-triggered compaction: interaction with the compression guards. + +The idle trigger (``agent/turn_context.py``, opt-in via +``compression.idle_compact_after_seconds``) does not compress anything +itself — it routes through ``agent._compress_context`` (the forwarder to +``agent.conversation_compression.compress_context``), which owns ALL of the +automatic-compaction guards: + +- the per-session compression lock (added after the idle-compaction PR was + written — an idle-triggered compress racing a turn-triggered one on the + same session_id must be serialized, not forked), +- the persisted summary-failure cooldown, +- the anti-thrash / fallback-streak breaker. + +These tests pin that composition end-to-end with a real ``AIAgent`` wired to +a real ``SessionDB``: when a guard says no, the idle path must be a strict +no-op for the turn (no compressor call, no session rotation, no flush +re-baseline, no user-message re-anchor). +""" + +from __future__ import annotations + +import time +import types +from pathlib import Path +from unittest.mock import MagicMock, patch + +from hermes_state import SessionDB + +from agent.turn_context import build_turn_context + +from tests.agent.test_compression_concurrent_fork import _build_agent_with_db + + +def _prep_idle_agent(db: SessionDB, session_id: str, *, idle_after: int = 60, + idle_gap: float = 3600.0): + """Real AIAgent (mock compressor) primed so the idle trigger is eligible.""" + agent = _build_agent_with_db(db, session_id) + agent.compression_enabled = True + agent.compression_idle_compact_after_seconds = idle_after + agent._last_activity_ts = time.time() - idle_gap + # The idle block reads these from the compressor; give the MagicMock real + # numbers so the floor computation and the preflight gate behave. + agent.context_compressor.threshold_tokens = 100_000 + agent.context_compressor.summary_target_ratio = 0.20 + agent.context_compressor.protect_first_n = 2 + agent.context_compressor.protect_last_n = 2 + # No active failure cooldown unless a test installs one. + agent.context_compressor.get_active_compression_failure_cooldown = ( + lambda *a, **k: None + ) + agent._cached_system_prompt = "SYSTEM" + return agent + + +def _run_prologue(agent, history, user_message="hello again"): + """Invoke ``build_turn_context`` the way ``conversation_loop`` does. + + The token-threshold preflight gate is pinned False so these tests + exercise the IDLE trigger in isolation (the preflight path has its own + coverage in ``test_turn_context.py``). + """ + with patch("agent.auxiliary_client.set_runtime_main", lambda *a, **k: None), \ + patch("agent.turn_context._should_run_preflight_estimate", + return_value=False), \ + patch("agent.turn_context.estimate_request_tokens_rough", + return_value=999_999): + return build_turn_context( + agent=agent, + user_message=user_message, + system_message=None, + conversation_history=history, + task_id=None, + stream_callback=None, + persist_user_message=None, + restore_or_build_system_prompt=lambda *a, **k: None, + install_safe_stdio=lambda: None, + sanitize_surrogates=lambda s: s, + summarize_user_message_for_log=lambda s: str(s), + set_session_context=lambda _sid: None, + set_current_write_origin=lambda _o: None, + ra=lambda: types.SimpleNamespace(_set_interrupt=lambda *a, **k: None), + ) + + +def _history(n: int = 20) -> list: + return [{"role": "user", "content": f"m{i}"} for i in range(n)] + + +def test_idle_compaction_runs_through_guarded_path_and_releases_lock( + tmp_path: Path, +) -> None: + """Happy path: the idle trigger reaches the real ``compress_context``. + + It must acquire + release the per-session lock, invoke the compressor + exactly once, and (rotation mode) rotate the session — proving the trigger + is wired through the guarded entrypoint rather than calling the compressor + directly. + """ + db = SessionDB(db_path=tmp_path / "state.db") + sid = "IDLE_HAPPY" + db.create_session(sid, source="cli") + agent = _prep_idle_agent(db, sid) + + ctx = _run_prologue(agent, _history()) + + agent.context_compressor.compress.assert_called_once() + # Rotation mode (in_place=False in the shared fixture) creates a child. + assert agent.session_id != sid + # The lock keyed on the OLD session id must not leak. + assert db.get_compression_lock_holder(sid) is None + # The turn continues on the compacted transcript, with the user-message + # anchor pointing at a live user row in the rebuilt list. + assert 0 <= ctx.current_turn_user_idx < len(ctx.messages) + assert ctx.messages[ctx.current_turn_user_idx].get("role") == "user" + + +def test_idle_compaction_defers_to_held_compression_lock(tmp_path: Path) -> None: + """An idle-triggered compress racing another path must sit the round out. + + The per-session lock landed after the idle-compaction PR: when another + path (turn-triggered preflight, background-review fork) already holds the + lock, ``compress_context`` returns the input list unchanged. The idle + block must treat that skip as a strict no-op: no compressor call, no + rotation, no flush re-baseline, anchor untouched. + """ + db = SessionDB(db_path=tmp_path / "state.db") + sid = "IDLE_LOCKED" + db.create_session(sid, source="cli") + assert db.try_acquire_compression_lock(sid, "external_holder") is True + + agent = _prep_idle_agent(db, sid) + history = _history() + + ctx = _run_prologue(agent, history) + + # Skipped: the compressor never ran and the session did not rotate. + agent.context_compressor.compress.assert_not_called() + assert agent.session_id == sid + # The external holder still owns the lock (we must not have stolen or + # released someone else's lease). + assert db.get_compression_lock_holder(sid) == "external_holder" + # Turn state untouched: full history + this turn's user message, anchor on + # the just-appended message, flush baseline not re-baselined to None-then- + # doubled semantics. + assert len(ctx.messages) == len(history) + 1 + assert ctx.current_turn_user_idx == len(ctx.messages) - 1 + assert ctx.messages[ctx.current_turn_user_idx]["content"] == "hello again" + + +def test_idle_compaction_respects_anti_thrash_breaker(tmp_path: Path) -> None: + """A tripped ineffective-compression breaker must block the idle trigger. + + The breaker lives in ``ContextCompressor._automatic_compression_blocked`` + and is consulted by ``compress_context`` for every non-forced entrypoint. + The idle path is an automatic entrypoint, so two prior ineffective + compactions must silence it too. + """ + from agent.context_compressor import ContextCompressor + + db = SessionDB(db_path=tmp_path / "state.db") + sid = "IDLE_THRASH" + db.create_session(sid, source="cli") + agent = _prep_idle_agent(db, sid) + + with patch( + "agent.context_compressor.get_model_context_length", return_value=100_000 + ): + compressor = ContextCompressor( + model="test/model", + threshold_percent=0.85, + protect_first_n=2, + protect_last_n=2, + quiet_mode=True, + ) + compressor.bind_session_state(db, sid) + compressor._ineffective_compression_count = 2 # breaker tripped + compressor.compress = MagicMock() + agent.context_compressor = compressor + + ctx = _run_prologue(agent, _history()) + + compressor.compress.assert_not_called() + assert agent.session_id == sid + assert len(ctx.messages) == len(_history()) + 1 + + +def test_idle_compaction_respects_persisted_failure_cooldown( + tmp_path: Path, +) -> None: + """An active summary-failure cooldown must gate the idle trigger up front. + + The idle predicate itself consults + ``get_active_compression_failure_cooldown`` — with a persisted cooldown in + state.db the trigger must not even reach ``_compress_context``. + """ + from agent.context_compressor import ContextCompressor + + db = SessionDB(db_path=tmp_path / "state.db") + sid = "IDLE_COOLDOWN" + db.create_session(sid, source="cli") + db.record_compression_failure_cooldown(sid, 4_000_000_000.0, "timeout") + + agent = _prep_idle_agent(db, sid) + with patch( + "agent.context_compressor.get_model_context_length", return_value=100_000 + ): + compressor = ContextCompressor( + model="test/model", + threshold_percent=0.85, + protect_first_n=2, + protect_last_n=2, + quiet_mode=True, + ) + compressor.bind_session_state(db, sid) + compressor.compress = MagicMock() + agent.context_compressor = compressor + agent._compress_context = MagicMock() + + ctx = _run_prologue(agent, _history()) + + agent._compress_context.assert_not_called() + compressor.compress.assert_not_called() + assert agent.session_id == sid + assert len(ctx.messages) == len(_history()) + 1 + + +def test_idle_compaction_disabled_by_default(tmp_path: Path) -> None: + """With the default config (0) a huge idle gap must never trigger.""" + db = SessionDB(db_path=tmp_path / "state.db") + sid = "IDLE_OFF" + db.create_session(sid, source="cli") + agent = _prep_idle_agent(db, sid, idle_after=0, idle_gap=10_000_000.0) + agent._compress_context = MagicMock() + + ctx = _run_prologue(agent, _history()) + + agent._compress_context.assert_not_called() + agent.context_compressor.compress.assert_not_called() + assert agent.session_id == sid + assert len(ctx.messages) == len(_history()) + 1 diff --git a/tests/gateway/test_telegram_noise_filter.py b/tests/gateway/test_telegram_noise_filter.py index 0a1a75bfe939..6d35e1e2fb69 100644 --- a/tests/gateway/test_telegram_noise_filter.py +++ b/tests/gateway/test_telegram_noise_filter.py @@ -31,6 +31,7 @@ CHAT_PLATFORMS = [ NOISY_STATUS_MESSAGES = [ "🗜️ Preflight compression check before sending...", "🗜️ Compacting context — summarizing earlier conversation so I can continue...", + "💤 Resumed after 3600s idle — compacting ~120,000 tokens before continuing.", "⚠️ Session compressed 12 times — accuracy may degrade. Consider /new to start fresh.", "⚠ Compression summary failed: upstream error. Inserted a fallback context marker.", "⏱️ Rate limited. Waiting 30.0s (attempt 2/3)...", diff --git a/website/docs/developer-guide/context-compression-and-caching.md b/website/docs/developer-guide/context-compression-and-caching.md index 74dfea7ead62..e3eb2c4d8f2d 100644 --- a/website/docs/developer-guide/context-compression-and-caching.md +++ b/website/docs/developer-guide/context-compression-and-caching.md @@ -108,6 +108,7 @@ auxiliary: | `target_ratio` | `0.20` | 0.10-0.80 | Controls tail protection token budget: `threshold_tokens × target_ratio` | | `protect_last_n` | `20` | ≥1 | Minimum number of recent messages always preserved | | `protect_first_n` | `3` | (hardcoded) | System prompt + first exchange always preserved | +| `idle_compact_after_seconds` | `0` | ≥0 seconds | Opt-in: compact up front when a session resumes after this many seconds idle (0 = disabled). Skips when context ≤ threshold × target_ratio; honors cooldown/anti-thrash/lock guards | | `codex_gpt55_autoraise` | `true` | bool | Raise the trigger to 85% for gpt-5.5 on the ChatGPT Codex OAuth route (see below). Set `false` to keep the global `threshold` | | `codex_gpt55_autoraise_notice` | `true` | bool | Show the one-time Codex gpt-5.5 autoraise notice. Set `false` to keep the 85% autoraise but suppress the banner | | `codex_app_server_auto` | `native` | `native`, `hermes`, `off` | Thread-compaction mode for Codex app-server sessions (see below) | diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index b1973865dfab..0f9c1892879d 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -748,6 +748,7 @@ compression: target_ratio: 0.20 # Fraction of threshold to preserve as recent tail protect_last_n: 20 # Min recent messages to keep uncompressed protect_first_n: 3 # Non-system head messages pinned across compactions (0 = pin nothing) + idle_compact_after_seconds: 0 # Opt-in idle compaction (0 = disabled) — see below hygiene_hard_message_limit: 5000 # Gateway safety valve — see below # The summarization model/provider is configured under auxiliary: @@ -768,6 +769,8 @@ Older configs with `compression.summary_model`, `compression.summary_provider`, `threshold_tokens` sets an optional **absolute token cap** for the compression trigger. When set, compression fires at the lower of the ratio-based `threshold` and this absolute count — so compression never fires later than the user's preferred token number regardless of which model is active. This solves the problem where switching between models with different context windows (e.g. 1M → 400K) shifts the absolute trigger point. The cap is clamped to the model's context length, so setting it higher than the model supports is safe — the ratio-based threshold is used instead. Default `null` (disabled — ratio-based threshold only). The cap survives model switches and fallback activations. +`idle_compact_after_seconds` is an **opt-in, time-based** trigger that complements the size-based `threshold`. Default `0` (disabled). When set above 0, a session that resumes after at least that many seconds of inactivity compacts its accumulated history up front, before the first reply — so a long-lived thread (e.g. a Telegram conversation you come back to hours later) doesn't re-read its full stale context on every subsequent turn. It never fires when the context is already at or below the post-compression target (`threshold × target_ratio`), and it honors the same failure-cooldown, anti-thrash, and per-session lock guards as every automatic compaction. Example: `idle_compact_after_seconds: 1800` compacts after 30 minutes idle. + :::tip Gateway hot-reload of compression and context length As of recent releases, editing `model.context_length` or any `compression.*` key in `config.yaml` on a running gateway takes effect on the next message — no gateway restart, no `/reset`, no session rotation required. The cached-agent signature includes these keys, so the gateway transparently rebuilds the agent when it sees a change. API keys and tool/skill config still require the usual reload paths. ::: From bcc3396b25ea51c8e61f9781c716d71ec414d0f3 Mon Sep 17 00:00:00 2001 From: "hermes-seaeye[bot]" <307254004+hermes-seaeye[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:30:14 +0000 Subject: [PATCH 009/238] fmt(js): `npm run fix` on merge (#69503) Co-authored-by: github-actions[bot] --- apps/desktop/electron/main.ts | 35 ++++++++----------- .../electron/native-auth-decisions.test.ts | 6 +--- .../desktop/electron/native-auth-decisions.ts | 4 +-- .../electron/native-oauth-login.test.ts | 4 +++ apps/desktop/electron/native-oauth-login.ts | 6 ++-- apps/desktop/electron/native-oauth.test.ts | 9 ++--- apps/desktop/electron/native-oauth.ts | 17 +++++---- 7 files changed, 38 insertions(+), 43 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 7bf789ccf118..ef68060cb5d1 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -80,19 +80,6 @@ import { installEmbedReferer } from './embed-referer' import { createEventDeduper } from './event-dedupe' import { readDirForIpc } from './fs-read-dir' import { probeGatewayWebSocket } from './gateway-ws-probe' -import { runNativeLogin } from './native-oauth-login' -import { - nativeRefreshUrl, - parseTokenResponse, - resolveLoginStrategy, - tokenNeedsRefresh, - type NativeTokenSet -} from './native-oauth' -import { - oauthSessionIsLive, - resolveJsonBody, - resolveOauthRestAuth -} from './native-auth-decisions' import { scanGitRepos } from './git-repo-scan' import { fileDiffVsHead, @@ -129,6 +116,15 @@ import { } from './hardening' import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' import { ensureMainWindow } from './main-window-lifecycle' +import { oauthSessionIsLive, resolveJsonBody, resolveOauthRestAuth } from './native-auth-decisions' +import { + nativeRefreshUrl, + type NativeTokenSet, + parseTokenResponse, + resolveLoginStrategy, + tokenNeedsRefresh +} from './native-oauth' +import { runNativeLogin } from './native-oauth-login' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' import { createKeepAwake } from './power-save' import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' @@ -5851,6 +5847,7 @@ async function ensureNativeAccessToken(baseUrl: string): Promise { refresh_token: tokens.refreshToken, provider: tokens.provider }, { timeoutMs: 10_000 } ) + const rotated = parseTokenResponse(body) _storeNativeTokens(baseUrl, rotated) @@ -5883,6 +5880,7 @@ async function mintGatewayWsTicket(baseUrl) { timeoutMs: 8_000, bearer: nativeAt })) as any + const ticket = body?.ticket if (!ticket || typeof ticket !== 'string') { @@ -6459,10 +6457,7 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon // RFC 8252 flow) counts as connected too — otherwise a completed native // sign-in shows "not connected" in Settings. The authoritative liveness // check is the ws-ticket mint in resolveRemoteBackend at actual connect time. - remoteOauthConnected = oauthSessionIsLive( - hasNativeSession(remoteUrl), - await hasLiveOauthSession(remoteUrl) - ) + remoteOauthConnected = oauthSessionIsLive(hasNativeSession(remoteUrl), await hasLiveOauthSession(remoteUrl)) } catch { remoteOauthConnected = false } @@ -8955,6 +8950,7 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => postJson: (url, body, opts) => postJsonNoAuth(url, body, opts), rememberLog }) + _storeNativeTokens(baseUrl, tokens) return { ok: true, baseUrl, connected: true } @@ -8977,6 +8973,7 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => { const baseUrl = rawUrl ? normalizeRemoteBaseUrl(rawUrl) : '' await clearOauthSession(baseUrl || undefined) + // Also drop any native (RFC 8252) bearer tokens for this gateway so a // logout clears BOTH auth shapes. if (baseUrl) { @@ -8986,9 +8983,7 @@ ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) = // Report against the SAME liveness notion the Settings indicator uses // (AT-or-RT cookie, or a native token) so a logout that left any session // behind is reflected as still-connected rather than silently signed-out. - const connected = baseUrl - ? (await hasLiveOauthSession(baseUrl)) || hasNativeSession(baseUrl) - : false + const connected = baseUrl ? (await hasLiveOauthSession(baseUrl)) || hasNativeSession(baseUrl) : false return { ok: true, connected } }) diff --git a/apps/desktop/electron/native-auth-decisions.test.ts b/apps/desktop/electron/native-auth-decisions.test.ts index 6343c4d1c1cc..09f648c87192 100644 --- a/apps/desktop/electron/native-auth-decisions.test.ts +++ b/apps/desktop/electron/native-auth-decisions.test.ts @@ -10,11 +10,7 @@ import assert from 'node:assert/strict' import { test } from 'vitest' -import { - oauthSessionIsLive, - resolveJsonBody, - resolveOauthRestAuth -} from './native-auth-decisions' +import { oauthSessionIsLive, resolveJsonBody, resolveOauthRestAuth } from './native-auth-decisions' // --- 1. body encoding (guards the double-JSON.stringify 422) --- diff --git a/apps/desktop/electron/native-auth-decisions.ts b/apps/desktop/electron/native-auth-decisions.ts index f85f8c59a575..d76746f669a3 100644 --- a/apps/desktop/electron/native-auth-decisions.ts +++ b/apps/desktop/electron/native-auth-decisions.ts @@ -44,9 +44,7 @@ export function oauthSessionIsLive(hasNativeToken: boolean, hasCookieSession: bo return hasNativeToken || hasCookieSession } -export type OauthRestAuth = - | { kind: 'bearer'; token: string } - | { kind: 'cookie' } +export type OauthRestAuth = { kind: 'bearer'; token: string } | { kind: 'cookie' } /** * Decide how an oauth-mode REST request authenticates: prefer the native diff --git a/apps/desktop/electron/native-oauth-login.test.ts b/apps/desktop/electron/native-oauth-login.test.ts index 23b7e9c7275c..6d1651bc715f 100644 --- a/apps/desktop/electron/native-oauth-login.test.ts +++ b/apps/desktop/electron/native-oauth-login.test.ts @@ -22,14 +22,18 @@ function makeFakeServerFactory(port = 51234) { const createServer: any = (handler: any) => { state.handler = handler const server: any = new EventEmitter() + server.listen = (_port: number, _host: string, cb: () => void) => { state.listening = true cb() } + server.address = () => ({ address: '127.0.0.1', family: 'IPv4', port }) + server.close = () => { state.closed = true } + state.server = server return server diff --git a/apps/desktop/electron/native-oauth-login.ts b/apps/desktop/electron/native-oauth-login.ts index 20d53d10ab7e..6179e30fab3a 100644 --- a/apps/desktop/electron/native-oauth-login.ts +++ b/apps/desktop/electron/native-oauth-login.ts @@ -32,10 +32,10 @@ import { buildNativeAuthorizeUrl, generatePkcePair, generateState, + type NativeTokenSet, nativeTokenUrl, parseLoopbackCallback, - parseTokenResponse, - type NativeTokenSet + parseTokenResponse } from './native-oauth' // Loopback login must complete inside this window (user opens browser, @@ -90,6 +90,7 @@ export async function runNativeLogin( return new Promise((resolve, reject) => { let settled = false let timer: NodeJS.Timeout | null = null + const server = createServer((req, res) => { // Only the callback path carries the code; any other path (favicon, // etc.) still gets the friendly page so the browser tab looks sane. @@ -179,6 +180,7 @@ export async function runNativeLogin( } const redirectUri = `http://127.0.0.1:${addr.port}/callback` + const authorizeUrl = buildNativeAuthorizeUrl(baseUrl, { challenge, redirectUri, diff --git a/apps/desktop/electron/native-oauth.test.ts b/apps/desktop/electron/native-oauth.test.ts index 2f3da633ccd3..58d863c4b1a6 100644 --- a/apps/desktop/electron/native-oauth.test.ts +++ b/apps/desktop/electron/native-oauth.test.ts @@ -34,6 +34,7 @@ test('generatePkcePair produces a valid S256 verifier/challenge', () => { assert.equal(pair.method, 'S256') // Verifier length within RFC 7636 range (43–128). assert.ok(pair.verifier.length >= 43 && pair.verifier.length <= 128) + // Challenge must be the base64url SHA-256 of the verifier. const expected = createHash('sha256') .update(pair.verifier, 'ascii') @@ -41,6 +42,7 @@ test('generatePkcePair produces a valid S256 verifier/challenge', () => { .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '') + assert.equal(pair.challenge, expected) // No padding / URL-unsafe chars. assert.doesNotMatch(pair.verifier, /[+/=]/) @@ -91,6 +93,7 @@ test('buildNativeAuthorizeUrl encodes params and honours a path prefix', () => { state: 'STATE', provider: 'nous' }) + const parsed = new URL(url) assert.equal(parsed.origin, 'https://gw.example.com') @@ -108,6 +111,7 @@ test('buildNativeAuthorizeUrl omits provider when not given and preserves prefix redirectUri: 'http://127.0.0.1:1/cb', state: 'S' }) + const parsed = new URL(url) assert.equal(parsed.pathname, '/hermes/auth/native/authorize') @@ -128,10 +132,7 @@ test('parseLoopbackCallback returns the code on a state match', () => { }) test('parseLoopbackCallback throws on state mismatch (CSRF)', () => { - assert.throws( - () => parseLoopbackCallback('/callback?code=abc&state=attacker', 'expected'), - /state mismatch/i - ) + assert.throws(() => parseLoopbackCallback('/callback?code=abc&state=attacker', 'expected'), /state mismatch/i) }) test('parseLoopbackCallback surfaces a gateway error param', () => { diff --git a/apps/desktop/electron/native-oauth.ts b/apps/desktop/electron/native-oauth.ts index 9e8bc6fdbd29..691cd32caca6 100644 --- a/apps/desktop/electron/native-oauth.ts +++ b/apps/desktop/electron/native-oauth.ts @@ -86,10 +86,7 @@ export function statusSupportsNativeFlow(statusBody: any): boolean { * (e.g. a corporate proxy that blocks loopback). Precedence written down here, * in one place, as a pure function — per the desktop "observable ladder" rule. */ -export function resolveLoginStrategy( - statusBody: any, - opts: { forceEmbedded?: boolean } = {} -): 'native' | 'embedded' { +export function resolveLoginStrategy(statusBody: any, opts: { forceEmbedded?: boolean } = {}): 'native' | 'embedded' { if (opts.forceEmbedded) { return 'embedded' } @@ -109,6 +106,7 @@ export function buildNativeAuthorizeUrl( ): string { const parsed = new URL(baseUrl) const prefix = parsed.pathname.replace(/\/+$/, '') + const q = new URLSearchParams({ code_challenge: params.challenge, code_challenge_method: 'S256', @@ -145,10 +143,7 @@ export function nativeRefreshUrl(baseUrl: string): string { * `expectedState` MUST match (CSRF defense — RFC 6749 §10.12); a mismatch * throws rather than proceeding. */ -export function parseLoopbackCallback( - requestUrl: string, - expectedState: string -): { code: string } { +export function parseLoopbackCallback(requestUrl: string, expectedState: string): { code: string } { // requestUrl is the path+query the loopback server received, e.g. // "/callback?code=...&state=...". Resolve against a dummy origin to parse. const parsed = new URL(requestUrl, 'http://127.0.0.1') @@ -203,7 +198,11 @@ export function parseTokenResponse(body: any): NativeTokenSet { * before use. `skewSeconds` refreshes slightly early to avoid a race where * the token expires in flight (mirrors the server's 60s cookie floor). */ -export function tokenNeedsRefresh(tokens: Pick, nowSeconds: number, skewSeconds = 60): boolean { +export function tokenNeedsRefresh( + tokens: Pick, + nowSeconds: number, + skewSeconds = 60 +): boolean { if (!tokens || !Number.isFinite(tokens.expiresAt) || tokens.expiresAt <= 0) { // Unknown expiry ⇒ treat as needing refresh so we validate before use. return true From 72dd01c55301fb7b87f0417d594d7738c91f3094 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 11:20:25 -0500 Subject: [PATCH 010/238] fix(desktop): no per-paragraph action bars on sealed interim messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since #65919 the live view seals each chunk of mid-turn assistant commentary (message.interim) as its own finalized bubble. Every bubble with visible text renders the hover action footer, so a tool-heavy turn grew a copy/refresh bar under almost every paragraph — and the live render didn't match rehydration, which merges the turn into one bubble. Mark sealed interim bubbles with ChatMessage.interim, carry the flag into the runtime message metadata (custom.interim), and skip the AssistantFooter for them. The turn's final reply keeps the footer; a previewed final that settles onto an interim bubble clears the mark so the settled reply regains its actions. interim joins COMPARED_FIELDS / chatMessagesEquivalent so flipping it repaints. Also fix an id-collision flake this surfaced: stream/interim bubble ids were Date.now()-only, so an interim seal and the next segment's first delta in the same millisecond reused the id and the new segment appended into the sealed bubble. Ids now include a monotonic sequence. --- .../session/hooks/use-message-stream/index.ts | 39 ++++++------ .../interim-sealing.test.tsx | 30 ++++++++++ .../hooks/use-session-actions/utils.ts | 7 ++- .../assistant-ui/thread/assistant-message.tsx | 6 +- .../assistant-ui/thread/streaming.test.tsx | 59 +++++++++++++++++++ apps/desktop/src/lib/chat-messages.ts | 4 ++ apps/desktop/src/lib/chat-runtime.ts | 3 +- 7 files changed, 127 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index d72633ad7e58..3fe175b0fdaf 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -55,6 +55,13 @@ interface QueuedStreamDeltas { reasoning: string } +// Date.now() alone can collide when an interim seal and the next segment's +// first delta land in the same millisecond — the new segment would then find +// the sealed bubble by id and append into it instead of starting fresh. +let streamMessageSeq = 0 + +const nextStreamMessageId = (prefix: string) => `${prefix}-${Date.now()}-${++streamMessageSeq}` + export function useMessageStream({ activeGatewayProfile = 'default', activeSessionIdRef, @@ -91,7 +98,7 @@ export function useMessageStream({ return state } - const streamId = state.streamId ?? `assistant-stream-${Date.now()}` + const streamId = state.streamId ?? nextStreamMessageId('assistant-stream') const groupId = state.pendingBranchGroup ?? undefined const prev = state.messages let nextMessages: ChatMessage[] @@ -397,19 +404,21 @@ export function useMessageStream({ let nextMessages = state.messages if (streamId && nextMessages.some(m => m.id === streamId)) { - // Finalize the existing streaming bubble in place + // Seal the streaming bubble in place, marked interim so it renders + // without an action footer (see ChatMessage.interim). nextMessages = nextMessages.map(m => - m.id === streamId ? { ...m, parts: replaceTextPart(m.parts), pending: false } : m + m.id === streamId ? { ...m, parts: replaceTextPart(m.parts), pending: false, interim: true } : m ) } else { // No streaming bubble — create a standalone interim message nextMessages = [ ...nextMessages, { - id: `assistant-interim-${Date.now()}`, + id: nextStreamMessageId('assistant-interim'), role: 'assistant' as const, parts: [assistantTextPart(authoritativeText)], pending: false, + interim: true, branchGroupId: state.pendingBranchGroup ?? undefined } ] @@ -459,19 +468,15 @@ export function useMessageStream({ return mergeFinalAssistantText(parts, visibleFinalText) } - const completeMessage = (message: ChatMessage): ChatMessage => - completionError - ? { - ...message, - error: completionError, - parts: message.parts.filter(part => part.type !== 'text'), - pending: false - } - : { - ...message, - parts: replaceTextPart(message.parts), - pending: false - } + // Settling the final response onto a bubble makes it the turn's real + // reply — clear `interim` so it regains the action footer. + const completeMessage = (message: ChatMessage): ChatMessage => { + const settled = { ...message, pending: false, interim: false } + + return completionError + ? { ...settled, error: completionError, parts: message.parts.filter(part => part.type !== 'text') } + : { ...settled, parts: replaceTextPart(message.parts) } + } const newAssistantFromCompletion = (): ChatMessage => ({ id: `assistant-${Date.now()}`, diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx index 2ee5bb720418..9a10e6c9b183 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx @@ -111,6 +111,36 @@ describe('useMessageStream interim text sealing', () => { expect(texts).toContain('All checks passed.') }) + it('marks sealed interim bubbles interim and leaves the final reply unmarked', async () => { + await mountStream() + await start() + + await delta('Let me check the files.') + await interim('Let me check the files.') + await delta('Now the second pass.') + await interim('Now the second pass.') + await complete('All done.') + + const assistants = getState().messages.filter(m => m.role === 'assistant' && !m.hidden) + const byText = (text: string) => assistants.find(m => chatMessageText(m) === text) + + expect(byText('Let me check the files.')?.interim).toBe(true) + expect(byText('Now the second pass.')?.interim).toBe(true) + expect(byText('All done.')?.interim).toBeFalsy() + }) + + it('clears the interim mark when a previewed final settles onto the interim bubble', async () => { + await mountStream() + await start() + + await interim('same reply') + await completePreviewed('same reply') + + const assistants = getState().messages.filter(m => m.role === 'assistant' && !m.hidden) + expect(assistants).toHaveLength(1) + expect(assistants[0].interim).toBeFalsy() + }) + it('dedupes interim text when the final response includes it', async () => { await mountStream() await start() diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index 927b72d6d414..feb22b9e658a 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -73,7 +73,7 @@ const _chatMessageFieldsExhaustive: { [K in Exclude]: never } = {} -const COMPARED_FIELDS = ['id', 'role', 'pending', 'error', 'hidden', 'branchGroupId'] as const +const COMPARED_FIELDS = ['id', 'role', 'pending', 'error', 'hidden', 'branchGroupId', 'interim'] as const const IGNORED_FIELDS = ['timestamp', 'attachmentRefs', 'parts'] as const // Compile-time check: every ChatMessagePart discriminant must be handled by @@ -154,7 +154,10 @@ export function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean a.pending !== b.pending || a.error !== b.error || a.hidden !== b.hidden || - a.branchGroupId !== b.branchGroupId + a.branchGroupId !== b.branchGroupId || + // Interim gates the action footer, so flipping it must repaint (e.g. a + // previewed final settling onto a sealed interim bubble restores the bar). + (a.interim ?? false) !== (b.interim ?? false) ) { return false } diff --git a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx index 5c73a80a5f72..e9ac8ad28a87 100644 --- a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx @@ -65,6 +65,10 @@ export const AssistantMessage: FC<{ const isRunning = messageStatus === 'running' const isPlaceholder = useAuiState(s => s.message.status?.type === 'running' && s.message.content.length === 0) const hasVisibleText = useAuiState(s => contentHasVisibleText(s.message.content)) + // Sealed mid-turn commentary keeps its text but not the footer, so a + // tool-heavy turn doesn't grow a copy/refresh bar per paragraph (see + // ChatMessage.interim). + const isInterim = useAuiState(s => s.message.metadata?.custom?.interim === true) // Preview targets only materialize once the turn completes — while running // the selector returns '' (stable), so per-token flushes skip the regex @@ -130,7 +134,7 @@ export const AssistantMessage: FC<{ - {hasVisibleText && ( + {hasVisibleText && !isInterim && ( )} diff --git a/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx b/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx index c78aeedc6e8a..8c31a9185f1d 100644 --- a/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/streaming.test.tsx @@ -328,6 +328,37 @@ function MessageHarness({ message }: { message: ThreadMessage }) { ) } +function TranscriptHarness({ messages }: { messages: ThreadMessage[] }) { + const runtime = useExternalStoreRuntime({ + messages, + isRunning: false, + onNew: async () => {} + }) + + return ( + + + + ) +} + +function assistantInterimMessage(text: string, id = 'assistant-interim-1'): ThreadMessage { + return { + id, + role: 'assistant', + content: [{ type: 'text', text }], + status: { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: { interim: true } + } + } as ThreadMessage +} + function RunningMessageHarness({ message }: { message: ThreadMessage }) { const runtime = useExternalStoreRuntime({ messages: [message], @@ -455,6 +486,34 @@ describe('assistant-ui streaming renderer', () => { expect(container.querySelector('[data-slot="aui_composer-clearance"]')).toBeNull() }) + it('suppresses the action footer on sealed interim messages, keeping it on the final reply', () => { + const { container } = render( + + ) + + // Interim commentary stays visible… + expect(container.textContent).toContain('Let me check the files.') + expect(container.textContent).toContain('Now applying the patch.') + expect(container.textContent).toContain('All done — patch applied.') + + // …but only the turn's final reply carries the copy/refresh action bar. + const actionBars = container.querySelectorAll('[data-slot="aui_msg-actions"]') + expect(actionBars).toHaveLength(1) + + const finalRoot = [...container.querySelectorAll('[data-slot="aui_assistant-message-root"]')].find(root => + root.textContent?.includes('All done — patch applied.') + ) + + expect(finalRoot?.querySelector('[data-slot="aui_msg-actions"]')).toBeTruthy() + }) + it('renders assistant provider errors inline', () => { render() diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index 4bc50d32569a..0a6d56d5a6b3 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -17,6 +17,10 @@ export type ChatMessage = { error?: string branchGroupId?: string hidden?: boolean + /** Sealed mid-turn commentary (`message.interim`) — rendered without the + * action footer so only the turn's final reply carries copy/refresh, and + * the live view matches rehydration (which merges the turn into one bubble). */ + interim?: boolean /** Composer attachment ref strings (`@file:...`, `@image:...`) sent with this user message. */ attachmentRefs?: string[] } diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index 775961d9546b..d2cacf876006 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -381,7 +381,8 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage { unstable_annotations: [], unstable_data: [], steps: [], - custom: {} + // Carries ChatMessage.interim to AssistantMessage's footer gate. + custom: message.interim ? { interim: true } : {} } } as ThreadMessage } From cbf5b05c704e3c6c3fb53b3dca2cbd6f0d1ff61e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 02:23:33 -0400 Subject: [PATCH 011/238] feat(agent): add active-turn redirect core primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A follow-up sent while the model is still generating previously ended the turn: Hermes kept only the visible partial text (reasoning was display-only), cleared the loop, and replayed the message as a fresh next turn. If the correction referred to something that only appeared in the thinking stream, the model no longer had that context. Add `AIAgent.redirect(text)`: a corrective interrupt distinct from a hard stop. It cancels only the in-flight model request (not tool workers or child agents), stashes the correction under a lock shared with `interrupt()` so a concurrent `/stop` always wins, and lets the loop rebuild the same logical iteration. `_apply_active_turn_redirect()` checkpoints the reasoning that was actually shown to the user plus any visible partial text as an ordinary assistant message, then appends the correction as a real user turn — never replaying incomplete signed/encrypted provider reasoning, and keeping strict role alternation and prompt-cache stability intact. During tool execution it degrades to `steer()` so a running tool finishes at a safe boundary. `_fire_reasoning_delta` now only records reasoning that a display callback actually consumed, so `show_reasoning: false` never leaks hidden provider thinking into the persisted transcript. --- agent/agent_init.py | 15 +++ agent/conversation_loop.py | 144 ++++++++++++++++++--- agent/turn_retry_state.py | 4 + run_agent.py | 179 ++++++++++++++++++++++++++- tests/agent/test_turn_retry_state.py | 1 + tests/run_agent/test_run_agent.py | 131 ++++++++++++++++++++ tests/run_agent/test_steer.py | 175 ++++++++++++++++++++++++++ 7 files changed, 627 insertions(+), 22 deletions(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index 2094dd2b4f7f..a6d3b0481411 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -720,6 +720,8 @@ def init_agent( agent._execution_thread_id: int | None = None # Set at run_conversation() start agent._interrupt_thread_signal_pending = False agent._client_lock = threading.RLock() + agent._model_request_active = threading.Event() + agent._supports_active_turn_redirect = True # /steer mechanism — inject a user note into the next tool result # without interrupting the agent. Unlike interrupt(), steer() does @@ -731,6 +733,13 @@ def init_agent( agent._pending_steer: Optional[str] = None agent._pending_steer_lock = threading.Lock() + # Active-turn redirect mechanism. A regular follow-up sent while the model + # is generating is different from a hard /stop: preserve the valid turn + # prefix, cancel only the in-flight model request, and rebuild its tail with + # the correction. The loop drains this slot at a role-safe boundary. + agent._pending_redirect: Optional[str] = None + agent._pending_redirect_lock = threading.Lock() + # Concurrent-tool worker thread tracking. `_execute_tool_calls_concurrent` # runs each tool on its own ThreadPoolExecutor worker — those worker # threads have tids distinct from `_execution_thread_id`, so @@ -897,6 +906,12 @@ def init_agent( agent._stream_writer_tls = threading.local() agent._stream_writer_dropped = 0 + # Displayed reasoning text streamed during the current model response, + # captured only when a surface consumed it via a reasoning callback. Used + # by active-turn redirect to checkpoint what the user actually saw without + # ever persisting hidden provider reasoning. + agent._current_streamed_reasoning_text = "" + # Optional current-turn user-message override used when the API-facing # user message intentionally differs from the persisted transcript # (e.g. CLI voice mode adds a temporary prefix for the live call only). diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index df81c20be211..fb9d11b66fce 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -103,6 +103,53 @@ _API_CALL_MODULES = frozenset({ }) +def _apply_active_turn_redirect(agent: Any, messages: List[Dict[str, Any]], text: str) -> None: + """Append a provider-safe checkpoint and correction to the live turn. + + Incomplete provider reasoning blocks are not valid replay items (Anthropic + signs them; Responses reasoning items require their following output). + Preserve only what Hermes actually displayed, demoted to ordinary text, + then add the correction as a real user message. This keeps role alternation + valid and leaves every previously cached message byte-for-byte unchanged. + """ + reasoning = str( + getattr(agent, "_current_streamed_reasoning_text", "") or "" + ).strip() + visible = agent._strip_think_blocks( + getattr(agent, "_current_streamed_assistant_text", "") or "" + ).strip() + + checkpoint_parts = ["[This response was interrupted by a user correction.]"] + if reasoning: + checkpoint_parts.extend( + ["Reasoning shown before the interruption:", reasoning] + ) + if visible: + checkpoint_parts.extend( + ["Visible response before the interruption:", visible] + ) + checkpoint = "\n\n".join(checkpoint_parts) + + # The normal live tail is user or tool, so an assistant checkpoint followed + # by the correction preserves strict alternation. If a transport already + # committed an assistant item, attribute the checkpoint inside the user + # correction instead of creating assistant→assistant. + if messages and messages[-1].get("role") == "assistant": + correction = ( + "[Context from the interrupted assistant response]\n" + f"{checkpoint}\n\n" + f"{text}" + ) + messages.append({"role": "user", "content": correction}) + else: + messages.append({"role": "assistant", "content": checkpoint}) + messages.append({"role": "user", "content": text}) + + agent._current_streamed_assistant_text = "" + agent._current_streamed_reasoning_text = "" + agent._stream_needs_break = True + + def _image_error_max_dimension(error: Exception) -> Optional[int]: """Extract a provider-reported image dimension ceiling, if present.""" parts = [] @@ -733,6 +780,16 @@ def run_conversation( ) while (api_call_count < agent.max_iterations and agent.iteration_budget.remaining > 0) or agent._budget_grace_call: + _redirect_text = agent._drain_pending_redirect() + if _redirect_text: + _apply_active_turn_redirect(agent, messages, _redirect_text) + if isinstance(original_user_message, str): + original_user_message = ( + f"{original_user_message}\n\n" + f"User correction during the turn: {_redirect_text}" + ) + agent._persist_session(messages, conversation_history) + # Reset per-turn checkpoint dedup so each iteration can take one snapshot agent._checkpoint_mgr.new_turn() @@ -1532,22 +1589,59 @@ def run_conversation( from hermes_cli.middleware import run_llm_execution_middleware - response = run_llm_execution_middleware( - api_kwargs, - _perform_api_call, - original_request=_original_api_kwargs, - task_id=effective_task_id, - turn_id=turn_id, - api_request_id=api_request_id, - session_id=agent.session_id or "", - platform=agent.platform or "", - model=agent.model, - provider=agent.provider, - base_url=agent.base_url, - api_mode=agent.api_mode, - api_call_count=api_call_count, - middleware_trace=list(_llm_middleware_trace), - ) + _model_request_active = getattr(agent, "_model_request_active", None) + _redirect_lock = getattr(agent, "_pending_redirect_lock", None) + if _redirect_lock is not None: + with _redirect_lock: + if _model_request_active is not None: + _model_request_active.set() + elif _model_request_active is not None: + _model_request_active.set() + _redirect_crossed_response = False + try: + response = run_llm_execution_middleware( + api_kwargs, + _perform_api_call, + original_request=_original_api_kwargs, + task_id=effective_task_id, + turn_id=turn_id, + api_request_id=api_request_id, + session_id=agent.session_id or "", + platform=agent.platform or "", + model=agent.model, + provider=agent.provider, + base_url=agent.base_url, + api_mode=agent.api_mode, + api_call_count=api_call_count, + middleware_trace=list(_llm_middleware_trace), + ) + finally: + if _redirect_lock is not None: + with _redirect_lock: + if _model_request_active is not None: + _model_request_active.clear() + _redirect_crossed_response = bool( + agent._pending_redirect + ) + else: + if _model_request_active is not None: + _model_request_active.clear() + _redirect_crossed_response = agent._has_pending_redirect() + if _redirect_crossed_response: + # The response and redirect can cross on different threads: + # redirect() observed the request as active just before this + # call returned. Discard that now-stale response and rebuild + # from the correction rather than silently losing it. + if thinking_spinner: + thinking_spinner.stop("") + thinking_spinner = None + if agent.thinking_callback: + agent.thinking_callback("") + if agent.clear_interrupt(preserve_redirect=True): + _retry.restart_with_redirected_messages = True + else: + interrupted = True + break api_duration = time.time() - api_start_time @@ -2509,6 +2603,15 @@ def run_conversation( thinking_spinner = None if agent.thinking_callback: agent.thinking_callback("") + if agent._has_pending_redirect(): + # redirect() deliberately used the interrupt machinery to + # cancel only this provider request. Keep its correction + # queued, clear the cancellation bit, and let the outer + # loop rebuild a clean request tail. Never materialize + # incomplete signed/encrypted reasoning items. + if agent.clear_interrupt(preserve_redirect=True): + _retry.restart_with_redirected_messages = True + break api_elapsed = time.time() - api_start_time agent._vprint(f"{agent.log_prefix}⚡ Interrupted during API call.", force=True) interrupted = True @@ -4457,6 +4560,15 @@ def run_conversation( f"{int(sleep_end - time.time())}s remaining" ) + if _retry.restart_with_redirected_messages: + # The cancelled request produced no valid assistant item. Reuse the + # same logical iteration after the outer loop appends the displayed + # partial context and correction to ``messages``. + api_call_count -= 1 + agent.iteration_budget.refund() + _retry.restart_with_redirected_messages = False + continue + # If the API call was interrupted, skip response processing if interrupted: _turn_exit_reason = "interrupted_during_api_call" diff --git a/agent/turn_retry_state.py b/agent/turn_retry_state.py index 3d231fef9ff4..59e343bfeda3 100644 --- a/agent/turn_retry_state.py +++ b/agent/turn_retry_state.py @@ -73,6 +73,10 @@ class TurnRetryState: # was rolled back off ``messages`` and the loop should re-issue the API # call against the newly-activated provider (#32421). restart_with_rebuilt_messages: bool = False + # A user correction cancelled the in-flight provider request. The outer + # loop must append a role-safe checkpoint + user message, rebuild the API + # payload, and retry the same logical iteration. + restart_with_redirected_messages: bool = False def __iter__(self): # Convenience for debugging / tests: iterate (name, value) pairs. diff --git a/run_agent.py b/run_agent.py index 7a37b9f3554b..4274c230e746 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2809,8 +2809,33 @@ class AIAgent: if session_has_running_agent: running_agent.interrupt(new_message.text) """ - self._interrupt_requested = True - self._interrupt_message = message + # A hard stop and redirect share one lock so /stop cannot race with an + # accepted correction and accidentally turn itself into a retry. + _redirect_lock = getattr(self, "_pending_redirect_lock", None) + if _redirect_lock is not None: + with _redirect_lock: + self._interrupt_requested = True + self._interrupt_message = message + self._pending_redirect = None + else: + self._interrupt_requested = True + self._interrupt_message = message + self._pending_redirect = None + + # Codex app-server owns its model/tool loop and watches a private + # interrupt event rather than Hermes' per-thread flag. + if getattr(self, "api_mode", None) == "codex_app_server": + _codex_session = getattr(self, "_codex_session", None) + _request_interrupt = getattr(_codex_session, "request_interrupt", None) + if callable(_request_interrupt): + try: + _request_interrupt() + except Exception: + logger.debug( + "Failed to interrupt Codex app-server turn", + exc_info=True, + ) + # A cron turn performs its API request on the conversation thread to # avoid the nested interrupt-worker deadlock. Unlike the normal worker # path, its client is registered here so this cross-thread interrupt can @@ -2863,10 +2888,29 @@ class AIAgent: if not self.quiet_mode: print("\n⚡ Interrupt requested" + (f": '{message[:40]}...'" if message and len(message) > 40 else f": '{message}'" if message else "")) - def clear_interrupt(self) -> None: - """Clear any pending interrupt request and the per-thread tool interrupt signal.""" - self._interrupt_requested = False - self._interrupt_message = None + def clear_interrupt(self, *, preserve_redirect: bool = False) -> bool: + """Clear the interrupt request and per-thread tool signal. + + ``preserve_redirect`` is used only by the conversation loop after it + intentionally cancels a model request to rebuild that same logical + turn. Public hard-stop paths keep the default and clear everything. + """ + _redirect_lock = getattr(self, "_pending_redirect_lock", None) + if _redirect_lock is not None: + with _redirect_lock: + if preserve_redirect and not self._pending_redirect: + return False + self._interrupt_requested = False + self._interrupt_message = None + if not preserve_redirect: + self._pending_redirect = None + else: + if preserve_redirect and not getattr(self, "_pending_redirect", None): + return False + self._interrupt_requested = False + self._interrupt_message = None + if not preserve_redirect: + self._pending_redirect = None self._interrupt_thread_signal_pending = False if self._execution_thread_id is not None: _set_interrupt(False, self._execution_thread_id) @@ -2895,6 +2939,7 @@ class AIAgent: if _steer_lock is not None: with _steer_lock: self._pending_steer = None + return True def steer(self, text: str) -> bool: """ @@ -2932,6 +2977,118 @@ class AIAgent: self._pending_steer = cleaned return True + def redirect(self, text: str) -> bool: + """Redirect the active turn without converting it into a new task. + + During a normal Hermes model request this cancels only that request; + the conversation loop retains completed messages/tool results, records + the displayed partial reasoning as plain assistant context, appends the + correction as a real user message, and retries. During tool execution + it degrades to ``steer()`` so the tool can finish at a safe boundary. + Codex app-server has a native ``turn/steer`` operation and uses it + directly instead of cancelling. + + Returns ``False`` when there is no live turn or the text is empty, so + surfaces can fall back to their existing next-turn queue. + """ + if not text or not text.strip(): + return False + cleaned = text.strip() + + # Codex owns its internal reasoning/tool loop, so use its first-class + # active-turn steering protocol rather than interrupting the subprocess. + if getattr(self, "api_mode", None) == "codex_app_server": + _codex_session = getattr(self, "_codex_session", None) + _native_steer = getattr(_codex_session, "request_steer", None) + if callable(_native_steer): + _redirect_lock = getattr(self, "_pending_redirect_lock", None) + if _redirect_lock is not None: + with _redirect_lock: + if self._interrupt_requested: + return False + elif self._interrupt_requested: + return False + try: + return bool(_native_steer(cleaned)) + except Exception: + logger.debug("Codex app-server turn/steer failed", exc_info=True) + return False + + # Never kill a tool merely to deliver conversational guidance. The + # existing steer drain puts it on the final tool result before the next + # model decision, including delegate_task children. + if getattr(self, "_executing_tools", False): + return self.steer(cleaned) + + _model_active = getattr(self, "_model_request_active", None) + _redirect_lock = getattr(self, "_pending_redirect_lock", None) + if _redirect_lock is None: + if _model_active is None or not _model_active.is_set(): + return False + existing = getattr(self, "_pending_redirect", None) + if self._interrupt_requested and not existing: + return False + self._pending_redirect = ( + f"{existing}\n\n[Additional user correction]\n{cleaned}" + if existing + else cleaned + ) + self._interrupt_requested = True + self._interrupt_message = None + else: + with _redirect_lock: + if _model_active is None or not _model_active.is_set(): + # The response completed before we acquired the state lock. + # Reject so the surface queues a new turn. + return False + if self._interrupt_requested and not self._pending_redirect: + return False + if self._pending_redirect: + self._pending_redirect = ( + f"{self._pending_redirect}\n\n" + f"[Additional user correction]\n{cleaned}" + ) + else: + self._pending_redirect = cleaned + self._interrupt_requested = True + self._interrupt_message = None + + # Interrupt only the model request. Do not fan out to tool workers or + # child agents as interrupt() does. + _execution_thread_id = getattr(self, "_execution_thread_id", None) + if _execution_thread_id is not None: + _set_interrupt(True, _execution_thread_id) + self._interrupt_thread_signal_pending = False + else: + self._interrupt_thread_signal_pending = True + _abort_active_request = getattr(self, "_active_request_abort", None) + if callable(_abort_active_request): + try: + _abort_active_request("redirect_abort") + except Exception: + logger.debug("Failed to abort request for redirect", exc_info=True) + return True + + def _has_pending_redirect(self) -> bool: + """Return whether an active-turn redirect is waiting to be applied.""" + _redirect_lock = getattr(self, "_pending_redirect_lock", None) + if _redirect_lock is None: + return bool(getattr(self, "_pending_redirect", None)) + with _redirect_lock: + return bool(self._pending_redirect) + + def _drain_pending_redirect(self) -> Optional[str]: + """Return and clear pending active-turn correction text.""" + _redirect_lock = getattr(self, "_pending_redirect_lock", None) + if _redirect_lock is None: + text = getattr(self, "_pending_redirect", None) + self._pending_redirect = None + return text + with _redirect_lock: + text = self._pending_redirect + self._pending_redirect = None + return text + def _drain_pending_steer(self) -> Optional[str]: """Return the pending steer text (if any) and clear the slot. @@ -4918,6 +5075,7 @@ class AIAgent: pass self._record_streamed_assistant_text(tail) self._current_streamed_assistant_text = "" + self._current_streamed_reasoning_text = "" def _record_streamed_assistant_text(self, text: str) -> None: """Accumulate visible assistant text emitted through stream callbacks.""" @@ -5258,6 +5416,15 @@ class AIAgent: cb(text) except Exception: pass + else: + # Only checkpoint reasoning that a surface actually displayed. + # show_reasoning=false leaves the callback unset, so hidden + # provider thinking never becomes visible transcript content. + if isinstance(text, str) and text: + self._current_streamed_reasoning_text = ( + getattr(self, "_current_streamed_reasoning_text", "") + + text + ) def _fire_tool_gen_started(self, tool_name: str) -> None: """Notify display layer that the model is generating tool call arguments. diff --git a/tests/agent/test_turn_retry_state.py b/tests/agent/test_turn_retry_state.py index 687a63f41899..89309f5cbef1 100644 --- a/tests/agent/test_turn_retry_state.py +++ b/tests/agent/test_turn_retry_state.py @@ -32,6 +32,7 @@ EXPECTED_FIELDS = { "restart_with_compressed_messages", "restart_with_length_continuation", "restart_with_rebuilt_messages", + "restart_with_redirected_messages", } diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 073bdd892c42..26d82cbcfdf6 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -12,6 +12,7 @@ import json import logging import re import threading +import time import uuid from logging.handlers import RotatingFileHandler from pathlib import Path @@ -4850,6 +4851,136 @@ class TestRunConversation: "content": "Sure, here's how to do it: first", } + def test_redirect_during_thinking_retries_same_turn_with_context(self, agent): + """A corrective follow-up keeps displayed reasoning and does not end the turn.""" + self._setup_agent(agent) + agent.reasoning_callback = lambda _text: None + final = _mock_response(content="Using Postgres instead.", finish_reason="stop") + requests = [] + persisted = [] + + def _fake_api_call(api_kwargs): + requests.append(api_kwargs) + if len(requests) == 1: + agent._fire_reasoning_delta("I should implement this with SQLite.") + assert agent.redirect("No, use Postgres instead.") is True + raise InterruptedError("redirect cancelled the first request") + return final + + with ( + patch.object(agent, "_interruptible_api_call", side_effect=_fake_api_call), + patch.object( + agent, + "_persist_session", + side_effect=lambda messages, *_a, **_k: persisted.append( + [dict(message) for message in messages] + ), + ), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("Choose a database and implement it.") + + assert result["completed"] is True + assert result["interrupted"] is False + assert result["final_response"] == "Using Postgres instead." + assert len(requests) == 2 + + replay = requests[1]["messages"] + assert [m["role"] for m in replay[-3:]] == [ + "user", + "assistant", + "user", + ] + checkpoint = replay[-2]["content"] + assert "interrupted by a user correction" in checkpoint + assert "I should implement this with SQLite." in checkpoint + assert replay[-1]["content"] == "No, use Postgres instead." + assert agent._pending_redirect is None + assert any( + snapshot[-1].get("content") == "No, use Postgres instead." + and snapshot[-2].get("role") == "assistant" + for snapshot in persisted + if len(snapshot) >= 2 + ) + + def test_redirect_wins_race_with_response_completion(self, agent): + """If the provider returns as redirect lands, discard the stale answer.""" + self._setup_agent(agent) + stale = _mock_response(content="Using SQLite.", finish_reason="stop") + corrected = _mock_response(content="Using Postgres.", finish_reason="stop") + calls = 0 + + def _fake_api_call(_api_kwargs): + nonlocal calls + calls += 1 + if calls == 1: + assert agent.redirect("Use Postgres instead.") is True + return stale + return corrected + + with ( + patch.object(agent, "_interruptible_api_call", side_effect=_fake_api_call), + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("Choose a database.") + + assert calls == 2 + assert result["final_response"] == "Using Postgres." + assert all( + message.get("content") != "Using SQLite." + for message in result["messages"] + ) + + def test_redirect_from_input_thread_cancels_live_model_request(self, agent): + """Exercise the real cross-thread path used by CLI and gateways.""" + self._setup_agent(agent) + agent.reasoning_callback = lambda _text: None + entered = threading.Event() + results = {} + calls = 0 + final = _mock_response(content="Corrected answer.", finish_reason="stop") + + def _fake_api_call(_api_kwargs): + nonlocal calls + calls += 1 + if calls == 1: + agent._fire_reasoning_delta("Following the original approach.") + entered.set() + deadline = time.time() + 2 + while not agent._interrupt_requested and time.time() < deadline: + time.sleep(0.01) + raise InterruptedError("request cancelled by redirect") + return final + + with ( + patch.object(agent, "_interruptible_api_call", side_effect=_fake_api_call), + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + worker = threading.Thread( + target=lambda: results.update( + result=agent.run_conversation("Take the original approach.") + ) + ) + worker.start() + assert entered.wait(timeout=2) + assert agent.redirect("Use the corrected approach.") is True + worker.join(timeout=5) + + assert worker.is_alive() is False + assert calls == 2 + assert results["result"]["completed"] is True + assert results["result"]["final_response"] == "Corrected answer." + checkpoint = results["result"]["messages"][-3] + assert "Following the original approach." in checkpoint["content"] + assert results["result"]["messages"][-2]["content"] == ( + "Use the corrected approach." + ) + def test_interrupt_before_any_stream_keeps_sentinel(self, agent): """An interrupt with no streamed text falls back to the metadata sentinel.""" from agent.conversation_loop import INTERRUPT_WAITING_FOR_MODEL_PREFIX diff --git a/tests/run_agent/test_steer.py b/tests/run_agent/test_steer.py index 99feb56343e7..ebf84c617d4b 100644 --- a/tests/run_agent/test_steer.py +++ b/tests/run_agent/test_steer.py @@ -23,6 +23,24 @@ def _bare_agent() -> AIAgent: agent = object.__new__(AIAgent) agent._pending_steer = None agent._pending_steer_lock = threading.Lock() + agent._pending_redirect = None + agent._pending_redirect_lock = threading.Lock() + agent._model_request_active = threading.Event() + agent._executing_tools = False + agent._execution_thread_id = None + agent._interrupt_thread_signal_pending = False + agent._interrupt_requested = False + agent._interrupt_message = None + agent._active_children = [] + agent._active_children_lock = threading.Lock() + agent._tool_worker_threads = None + agent._tool_worker_threads_lock = None + agent._current_streamed_reasoning_text = "" + agent._current_streamed_assistant_text = "" + agent._stream_needs_break = False + agent._strip_think_blocks = lambda content: content + agent.quiet_mode = True + agent.api_mode = "chat_completions" return agent @@ -72,6 +90,161 @@ class TestSteerDrain: assert agent._drain_pending_steer() is None +class TestActiveTurnRedirect: + def test_rejects_when_no_turn_is_active(self): + agent = _bare_agent() + assert agent.redirect("change course") is False + assert agent._pending_redirect is None + + def test_cancels_only_an_active_model_request(self): + agent = _bare_agent() + agent._model_request_active.set() + + assert agent.redirect("use Postgres") is True + assert agent._pending_redirect == "use Postgres" + assert agent._interrupt_requested is True + assert agent._interrupt_message is None + + def test_multiple_redirects_preserve_message_boundaries(self): + agent = _bare_agent() + agent._model_request_active.set() + + assert agent.redirect("first correction") is True + assert agent.redirect("second correction") is True + assert agent._pending_redirect == ( + "first correction\n\n" + "[Additional user correction]\n" + "second correction" + ) + + def test_hard_interrupt_wins_over_new_redirect(self): + agent = _bare_agent() + agent._model_request_active.set() + agent._interrupt_requested = True + + assert agent.redirect("too late") is False + assert agent._pending_redirect is None + + def test_hidden_reasoning_is_not_checkpointed(self): + agent = _bare_agent() + agent.reasoning_callback = None + agent._current_streamed_reasoning_text = "" + + agent._fire_reasoning_delta("private provider thinking") + + assert agent._current_streamed_reasoning_text == "" + + def test_response_completion_before_redirect_lock_rejects_correction(self): + agent = _bare_agent() + agent._model_request_active.set() + started = threading.Event() + outcome = {} + + def redirect(): + started.set() + outcome["accepted"] = agent.redirect("late correction") + + with agent._pending_redirect_lock: + worker = threading.Thread(target=redirect) + worker.start() + assert started.wait(timeout=1) + # Mirrors conversation_loop clearing the request-active marker + # under this same lock before redirect can commit its slot. + agent._model_request_active.clear() + worker.join(timeout=1) + + assert outcome["accepted"] is False + assert agent._pending_redirect is None + + def test_hard_stop_wins_concurrent_redirect(self): + agent = _bare_agent() + agent._model_request_active.set() + start = threading.Barrier(3) + outcome = {} + + def redirect(): + start.wait() + outcome["redirect"] = agent.redirect("change course") + + def hard_stop(): + start.wait() + agent.interrupt("stop requested") + + redirect_thread = threading.Thread(target=redirect) + stop_thread = threading.Thread(target=hard_stop) + redirect_thread.start() + stop_thread.start() + start.wait() + redirect_thread.join(timeout=1) + stop_thread.join(timeout=1) + + assert redirect_thread.is_alive() is False + assert stop_thread.is_alive() is False + assert agent._interrupt_requested is True + assert agent._interrupt_message == "stop requested" + assert agent._pending_redirect is None + + def test_codex_app_server_hard_stop_reaches_native_session(self): + agent = _bare_agent() + calls = [] + agent.api_mode = "codex_app_server" + agent._codex_session = type( + "_CodexSession", + (), + {"request_interrupt": lambda self: calls.append("interrupt")}, + )() + + agent.interrupt() + + assert calls == ["interrupt"] + + def test_codex_app_server_redirect_rejects_after_hard_stop(self): + agent = _bare_agent() + calls = [] + agent.api_mode = "codex_app_server" + agent._interrupt_requested = True + agent._codex_session = type( + "_CodexSession", + (), + {"request_steer": lambda self, text: calls.append(text) or True}, + )() + + assert agent.redirect("too late") is False + assert calls == [] + + def test_redirect_during_tool_execution_uses_safe_steer_boundary(self): + agent = _bare_agent() + agent._executing_tools = True + + assert agent.redirect("also check migrations") is True + assert agent._pending_redirect is None + assert agent._pending_steer == "also check migrations" + assert agent._interrupt_requested is False + + +class TestActiveTurnRedirectCheckpoint: + def test_assistant_tail_puts_correction_last(self): + from agent.conversation_loop import _apply_active_turn_redirect + + agent = _bare_agent() + agent._current_streamed_reasoning_text = "Shown reasoning." + agent._current_streamed_assistant_text = "Visible draft." + messages = [ + {"role": "user", "content": "start"}, + {"role": "assistant", "content": "committed assistant item"}, + ] + + _apply_active_turn_redirect(agent, messages, "Use Postgres instead.") + + assert [m["role"] for m in messages] == ["user", "assistant", "user"] + assert messages[-1]["role"] == "user" + assert messages[-1]["content"].endswith("Use Postgres instead.") + assert sum(1 for m in messages if m["role"] == "assistant") == 1 + assert "Shown reasoning." in messages[-1]["content"] + assert "Visible draft." in messages[-1]["content"] + assert "Context from the interrupted assistant response" in messages[-1]["content"] + + class TestSteerInjection: def test_appends_to_last_tool_result(self): agent = _bare_agent() @@ -196,10 +369,12 @@ class TestSteerClearedOnInterrupt: agent._tool_worker_threads_lock = None agent.steer("will be dropped") + agent._pending_redirect = "also drop this" assert agent._pending_steer == "will be dropped" agent.clear_interrupt() assert agent._pending_steer is None + assert agent._pending_redirect is None class TestPreApiCallSteerDrain: From f6d2ee4afc25dea1b4df64c5be89115eb432bde6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 02:23:39 -0400 Subject: [PATCH 012/238] feat(codex): honor redirect and hard stop in the app-server runtime The Codex app-server runtime bypasses the main conversation loop and drives its own subprocess turn, so it needs first-class hooks rather than the OpenAI-loop interrupt path. - `AIAgent.interrupt()` now forwards a hard stop to `CodexAppServerSession.request_interrupt()`, and `redirect()` uses Codex's native `turn/steer` protocol instead of cancelling the subprocess. - `run_turn()` no longer clears an interrupt that arrived during `ensure_started()`: a stop landing mid-startup is honored before `turn/start`, and the interrupt event is cleared on every exit path. - `run_codex_app_server_turn()` mirrors the loop finalizer's interrupt handoff (surface `interrupted` / `interrupt_message`, then `clear_interrupt()`) on both the normal and exception early-return paths, so a hard stop can't leave `_interrupt_requested` stale for the next turn. --- agent/codex_runtime.py | 34 +++++++++++++ agent/transports/codex_app_server_session.py | 47 ++++++++++++++++- tests/agent/test_codex_app_server_persist.py | 28 ++++++++++ .../test_codex_app_server_session.py | 51 ++++++++++++++----- 4 files changed, 146 insertions(+), 14 deletions(-) diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 91c2af3e995f..59f9bac25a26 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -702,6 +702,16 @@ def run_codex_app_server_turn( except Exception: pass agent._codex_session = None + _user_interrupted = bool( + getattr(agent, "_interrupt_requested", False) + ) + _interrupt_message = ( + getattr(agent, "_interrupt_message", None) + if _user_interrupted + else None + ) + if _user_interrupted: + agent.clear_interrupt() return { "final_response": ( f"Codex app-server turn failed: {exc}. " @@ -711,9 +721,27 @@ def run_codex_app_server_turn( "api_calls": 0, "completed": False, "partial": True, + "interrupted": _user_interrupted, + **( + {"interrupt_message": _interrupt_message} + if _interrupt_message + else {} + ), "error": str(exc), } + # This runtime bypasses the normal conversation-loop finalizer. Mirror its + # interrupt handoff/cleanup so a hard stop cannot poison the next turn and a + # message-bearing compatibility interrupt can still be replayed by callers. + _user_interrupted = bool( + turn.interrupted and getattr(agent, "_interrupt_requested", False) + ) + _interrupt_message = ( + getattr(agent, "_interrupt_message", None) if _user_interrupted else None + ) + if _user_interrupted: + agent.clear_interrupt() + # If the turn signalled the underlying client is wedged (deadline # blown, post-tool watchdog tripped, OAuth refresh died, subprocess # exited), retire the session so the next turn respawns codex @@ -819,6 +847,12 @@ def run_codex_app_server_turn( "api_calls": api_calls, "completed": not turn.interrupted and turn.error is None, "partial": turn.interrupted or turn.error is not None, + "interrupted": _user_interrupted, + **( + {"interrupt_message": _interrupt_message} + if _interrupt_message + else {} + ), "error": turn.error, # The codex app-server runtime IS an early-return path that bypasses # conversation_loop, but we flush the projected assistant/tool messages diff --git a/agent/transports/codex_app_server_session.py b/agent/transports/codex_app_server_session.py index 2e1cc64bdcbc..7954ecbf4d52 100644 --- a/agent/transports/codex_app_server_session.py +++ b/agent/transports/codex_app_server_session.py @@ -300,6 +300,8 @@ class CodexAppServerSession: self._client: Optional[CodexAppServerClient] = None self._thread_id: Optional[str] = None self._interrupt_event = threading.Event() + self._active_turn_id: Optional[str] = None + self._active_turn_lock = threading.Lock() # Pending file-change items, keyed by item id. Populated on # item/started for fileChange items; consumed by the approval # bridge when codex sends item/fileChange/requestApproval. The @@ -374,6 +376,8 @@ class CodexAppServerSession: if self._closed: return self._closed = True + with self._active_turn_lock: + self._active_turn_id = None if self._client is not None: try: self._client.close() @@ -395,6 +399,33 @@ class CodexAppServerSession: and unwind. Called by AIAgent's _interrupt_requested path.""" self._interrupt_event.set() + def request_steer(self, text: str) -> bool: + """Append user guidance to the active Codex turn via ``turn/steer``.""" + cleaned = str(text or "").strip() + if not cleaned: + return False + with self._active_turn_lock: + turn_id = self._active_turn_id + thread_id = self._thread_id + client = self._client + if not turn_id or not thread_id or client is None: + return False + try: + response = client.request( + "turn/steer", + { + "threadId": thread_id, + "input": [{"type": "text", "text": cleaned}], + "expectedTurnId": turn_id, + }, + timeout=10, + ) + except (CodexAppServerError, TimeoutError): + logger.debug("turn/steer rejected for active Codex turn", exc_info=True) + return False + accepted_turn_id = response.get("turnId") if isinstance(response, dict) else None + return accepted_turn_id in {None, turn_id} + # ---------- diagnostics ---------- def _format_error_with_stderr( @@ -469,11 +500,18 @@ class CodexAppServerSession: # Subprocess almost certainly unhealthy — retire so the next # turn re-spawns cleanly. result.should_retire = True + self._interrupt_event.clear() return result assert self._client is not None and self._thread_id is not None result.thread_id = self._thread_id - self._interrupt_event.clear() + # Do not clear here: a hard stop can arrive while ensure_started() is + # spawning/initializing the subprocess. Honor it before launching a + # Codex turn instead of erasing the signal. + if self._interrupt_event.is_set(): + result.interrupted = True + self._interrupt_event.clear() + return result projector = CodexEventProjector() user_input_text = _coerce_turn_input_text(user_input) @@ -505,6 +543,7 @@ class CodexAppServerSession: result.error = self._format_error_with_stderr( "turn/start failed", exc ) + self._interrupt_event.clear() return result except TimeoutError as exc: # turn/start hanging is a strong signal the subprocess is wedged. @@ -514,9 +553,12 @@ class CodexAppServerSession: "turn/start timed out", exc ) result.should_retire = True + self._interrupt_event.clear() return result result.turn_id = (ts.get("turn") or {}).get("id") + with self._active_turn_lock: + self._active_turn_id = result.turn_id deadline = time.monotonic() + turn_timeout turn_complete = False # Post-tool watchdog state. last_tool_completion_at is set whenever @@ -741,6 +783,9 @@ class CodexAppServerSession: ) result.should_retire = True + with self._active_turn_lock: + self._active_turn_id = None + self._interrupt_event.clear() return result def compact_thread( diff --git a/tests/agent/test_codex_app_server_persist.py b/tests/agent/test_codex_app_server_persist.py index 001082e3f0ea..85d4a5757f7e 100644 --- a/tests/agent/test_codex_app_server_persist.py +++ b/tests/agent/test_codex_app_server_persist.py @@ -76,6 +76,34 @@ def test_codex_success_flushes_and_reports_persisted(): assert result["agent_persisted"] is True +def test_codex_user_interrupt_is_reported_and_cleared(): + agent = _make_agent(session_db=None) + turn = _make_turn() + turn.interrupted = True + turn.final_text = "" + agent._codex_session.run_turn.return_value = turn + agent._interrupt_requested = True + agent._interrupt_message = "new correction" + + def clear_interrupt(): + agent._interrupt_requested = False + agent._interrupt_message = None + + agent.clear_interrupt.side_effect = clear_interrupt + result = run_codex_app_server_turn( + agent, + user_message="hello", + original_user_message="hello", + messages=[{"role": "user", "content": "hello"}], + effective_task_id="task-1", + ) + + assert result["interrupted"] is True + assert result["interrupt_message"] == "new correction" + agent.clear_interrupt.assert_called_once_with() + assert agent._interrupt_requested is False + + def test_codex_turn_persists_each_message_exactly_once(): """The user turn (flushed at turn start) must not be duplicated; the projected assistant message must land once. Uses a real SessionDB and the diff --git a/tests/agent/transports/test_codex_app_server_session.py b/tests/agent/transports/test_codex_app_server_session.py index 5479c14ae21e..af850fc0f8d5 100644 --- a/tests/agent/transports/test_codex_app_server_session.py +++ b/tests/agent/transports/test_codex_app_server_session.py @@ -57,6 +57,8 @@ class FakeClient: return {"turn": {"id": "turn-fake-001"}} if method == "turn/interrupt": return {} + if method == "turn/steer": + return {"turnId": (params or {}).get("expectedTurnId")} return {} def notify(self, method: str, params=None): @@ -562,30 +564,53 @@ class TestRunTurn: assert r.should_retire is True assert r.final_text == "" - def test_interrupt_during_turn_issues_turn_interrupt(self): + def test_interrupt_during_startup_skips_turn_start(self): client = FakeClient() - # Don't queue turn/completed — the loop has to interrupt out - client.queue_notification( - "item/completed", - item={"type": "commandExecution", "id": "x", "command": "sleep 60", - "cwd": "/", "status": "inProgress", - "aggregatedOutput": None, "exitCode": None, - "commandActions": []}, - threadId="t", turnId="tu1", - ) s = make_session(client) s.ensure_started() - # Trip the interrupt before run_turn even consumes the notification. - # The loop will see interrupt set on its first iteration and bail. s.request_interrupt() r = s.run_turn("loop forever", turn_timeout=2.0) + + assert r.interrupted is True + assert not any(method == "turn/start" for method, _params in client.requests) + + def test_interrupt_after_turn_start_issues_turn_interrupt(self): + client = FakeClient() + s = make_session(client) + + def request_handler(method, params): + if method == "thread/start": + return {"thread": {"id": "thread-fake-001"}} + if method == "turn/start": + s.request_interrupt() + return {"turn": {"id": "turn-fake-001"}} + return {} + + client._request_handler = request_handler + r = s.run_turn("loop forever", turn_timeout=2.0) + assert r.interrupted is True - # turn/interrupt was requested with the right turnId assert any( method == "turn/interrupt" and params.get("turnId") == "turn-fake-001" for (method, params) in client.requests ) + def test_steer_appends_input_to_active_turn(self): + client = FakeClient() + s = make_session(client) + s.ensure_started() + with s._active_turn_lock: + s._active_turn_id = "turn-live-123" + + assert s.request_steer("Use Postgres instead") is True + method, params = client.requests[-1] + assert method == "turn/steer" + assert params == { + "threadId": "thread-fake-001", + "input": [{"type": "text", "text": "Use Postgres instead"}], + "expectedTurnId": "turn-live-123", + } + def test_deadline_exceeded_records_error(self): client = FakeClient() # No notifications and no completion → must hit deadline From f071f42244bd6bdc512dbf7221008f23f5727c14 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 12:03:24 -0500 Subject: [PATCH 013/238] feat(desktop): let the agent open the preview pane Add a desktop-gated open_preview tool so 'open cnn.com in the preview pane' works. The tool (check_fn on HERMES_DESKTOP, zero footprint elsewhere) emits a preview.open event through a gateway-injected emitter, mirroring the close_terminal -> terminal.close bridge. The desktop handles it in usePreviewRouting, normalizing the target and opening the pane for the active session only -- a background turn never hijacks it. Bare domains and localhost are coaxed into fetchable URLs (www.cnn.com -> https://, localhost:3000 -> http://); file paths and schemes pass through to the renderer's normalizer. --- .../hooks/use-preview-routing.test.tsx | 36 ++++++ .../app/session/hooks/use-preview-routing.ts | 25 +++- tests/tools/test_open_preview_tool.py | 78 ++++++++++++ tools/open_preview_tool.py | 113 ++++++++++++++++++ toolsets.py | 8 +- tui_gateway/server.py | 25 ++++ 6 files changed, 280 insertions(+), 5 deletions(-) create mode 100644 tests/tools/test_open_preview_tool.py create mode 100644 tools/open_preview_tool.py diff --git a/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx b/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx index 3af7455a9535..e62cfb4ad071 100644 --- a/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-preview-routing.test.tsx @@ -120,6 +120,42 @@ describe('usePreviewRouting', () => { expect(window.hermesDesktop.normalizePreviewTarget).not.toHaveBeenCalled() }) + it('opens the preview pane on a preview.open event for the active session', async () => { + render( + { + handleEvent = handler + }} + /> + ) + + act(() => + handleEvent({ payload: { url: 'https://www.cnn.com', label: 'CNN' }, session_id: 'session-1', type: 'preview.open' }) + ) + + await waitFor(() => { + expect($previewTarget.get()).toMatchObject({ kind: 'url', label: 'CNN', url: 'https://www.cnn.com' }) + }) + }) + + it('ignores a preview.open event for a background session', async () => { + render( + { + handleEvent = handler + }} + /> + ) + + act(() => + handleEvent({ payload: { url: 'https://www.cnn.com' }, session_id: 'other-session', type: 'preview.open' }) + ) + + // Give any (wrongly) scheduled async open a tick to resolve before asserting. + await Promise.resolve() + expect($previewTarget.get()).toBeNull() + }) + it('does not auto-open a preview from tool results', async () => { render( { baseHandleGatewayEvent(event) + if (event.type === 'preview.open') { + // Agent-driven open in response to an explicit user request ("show + // cnn.com in the preview pane"). Honor it only for the active session — + // a background turn must not yank the pane open (see desktop AGENTS.md: + // offer, don't hijack). Routes through the same normalizer as the file + // browser so URLs, localhost, and file paths all resolve correctly. + const { url, label } = asRecord(event.payload) + const target = typeof url === 'string' ? url.trim() : '' + + if (target && (!event.session_id || event.session_id === activeSessionIdRef.current)) { + void normalizeOrLocalPreviewTarget(target, $currentCwd.get() || currentCwd || undefined).then(resolved => { + if (resolved) { + const trimmedLabel = typeof label === 'string' ? label.trim() : '' + setCurrentSessionPreviewTarget(trimmedLabel ? { ...resolved, label: trimmedLabel } : resolved, 'tool-result') + } + }) + } + + return + } + if (event.type === 'preview.restart.complete') { const { task_id, text } = asRecord(event.payload) @@ -126,7 +149,7 @@ export function usePreviewRouting({ requestPreviewReload() } }, - [activeSessionIdRef, baseHandleGatewayEvent] + [activeSessionIdRef, baseHandleGatewayEvent, currentCwd] ) return { handleDesktopGatewayEvent, restartPreviewServer } diff --git a/tests/tools/test_open_preview_tool.py b/tests/tools/test_open_preview_tool.py new file mode 100644 index 000000000000..db30f7e5e71b --- /dev/null +++ b/tests/tools/test_open_preview_tool.py @@ -0,0 +1,78 @@ +"""Tests for the desktop-gated ``open_preview`` tool.""" + +import json + +import pytest + +import tools.open_preview_tool as op + + +@pytest.fixture(autouse=True) +def _reset_emitter(): + """Each test controls the emitter; never leak one across tests.""" + op.set_preview_emitter(None) + yield + op.set_preview_emitter(None) + + +def test_gated_on_desktop(monkeypatch): + """Hidden unless HERMES_DESKTOP is set (mirrors read_terminal/close_terminal).""" + monkeypatch.delenv("HERMES_DESKTOP", raising=False) + assert op.check_open_preview_requirements() is False + + monkeypatch.setenv("HERMES_DESKTOP", "1") + assert op.check_open_preview_requirements() is True + + +def test_requires_url(): + op.set_preview_emitter(lambda *a: None) + assert json.loads(op.open_preview_tool(" "))["error"] + + +def test_desktop_only_without_emitter(): + """No emitter wired (CLI/messaging) → clear desktop-only error, no raise.""" + result = json.loads(op.open_preview_tool("https://example.com")) + assert "desktop" in result["error"].lower() + + +def test_emits_with_ui_session_id(monkeypatch): + """The tool routes (sid, url, label) to the wired emitter, sid from context.""" + monkeypatch.setattr(op, "get_session_env", lambda name, default="": "win-42" if name == "HERMES_UI_SESSION_ID" else default) + calls = [] + op.set_preview_emitter(lambda sid, url, label: calls.append((sid, url, label))) + + out = json.loads(op.open_preview_tool("https://example.com/app", label="Docs")) + + assert out["success"] is True + assert out["url"] == "https://example.com/app" + assert calls == [("win-42", "https://example.com/app", "Docs")] + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("www.cnn.com", "https://www.cnn.com"), + ("example.com/path", "https://example.com/path"), + ("localhost:3000", "http://localhost:3000"), + ("127.0.0.1:8080/x", "http://127.0.0.1:8080/x"), + ("https://already.example", "https://already.example"), + ("/abs/path/index.html", "/abs/path/index.html"), + ("./rel/page.html", "./rel/page.html"), + ("`https://tick.example`", "https://tick.example"), + ], +) +def test_normalizes_bare_targets(raw, expected): + seen = {} + op.set_preview_emitter(lambda sid, url, label: seen.update(url=url)) + + op.open_preview_tool(raw) + + assert seen["url"] == expected + + +def test_emitter_failure_is_reported(monkeypatch): + def _boom(*_a): + raise RuntimeError("no window") + + op.set_preview_emitter(_boom) + assert "no window" in json.loads(op.open_preview_tool("https://x.example"))["error"] diff --git a/tools/open_preview_tool.py b/tools/open_preview_tool.py new file mode 100644 index 000000000000..371beaa7b77c --- /dev/null +++ b/tools/open_preview_tool.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Open a URL, dev server, or file in the Hermes desktop GUI's preview pane. + +The preview pane lives in the desktop renderer, so this tool bridges through a +gateway-injected emitter: the desktop ``tui_gateway`` wires ``set_preview_emitter`` +at session start to emit a ``preview.open`` event the renderer handles (opening +the pane beside the chat, scoped to the window that asked). Like ``read_terminal`` +and ``close_terminal`` it is gated on ``HERMES_DESKTOP`` so it never appears +outside the GUI. Fire-and-forget: the renderer never steals focus for a +background session. +""" + +import json +import re +from typing import Callable, Optional + +from gateway.session_context import get_session_env +from tools.registry import registry, tool_error +from utils import env_var_enabled + +# Set by the desktop gateway (tui_gateway) to bridge this tool → a renderer +# event. ``None`` everywhere else, which is how the tool reports "desktop only". +_preview_emitter: Optional[Callable[[str, str, str], None]] = None + + +def set_preview_emitter(fn: Optional[Callable[[str, str, str], None]]) -> None: + """Install the (sid, url, label) → emit sink. Called by the desktop gateway.""" + global _preview_emitter + _preview_emitter = fn + + +def _normalize_target(raw: str) -> str: + """Coax a bare host/domain into a fetchable URL; leave paths + schemes alone. + + ``www.cnn.com`` → ``https://www.cnn.com``; ``localhost:3000`` → + ``http://localhost:3000``. File paths and explicit schemes pass through for + the renderer's preview normalizer to classify. + """ + v = raw.strip().strip("`").strip() + if not v or "://" in v or v.startswith(("/", "./", "../", "~", "file:")): + return v + if re.match(r"^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(:\d+)?(/|$)", v, re.I): + return "http://" + v + if re.match(r"^[\w.-]+\.[a-z]{2,}(:\d+)?(/.*)?$", v, re.I): + return "https://" + v + return v + + +def open_preview_tool(url: str, label: str = "") -> str: + """Ask the desktop GUI to show ``url`` in the preview pane beside the chat.""" + target = _normalize_target(url or "") + if not target: + return tool_error( + "url is required — a web URL (https://…), a localhost dev server, or a " + "file path to show in the preview pane." + ) + + emit = _preview_emitter + if emit is None: + return tool_error("The preview pane is only available in the Hermes desktop app.") + + label = (label or "").strip() + try: + emit(get_session_env("HERMES_UI_SESSION_ID", ""), target, label) + except Exception as exc: + return tool_error(f"Failed to open the preview pane: {exc}") + + return json.dumps({"success": True, "url": target, "label": label}, ensure_ascii=False) + + +def check_open_preview_requirements() -> bool: + """Desktop GUI only — HERMES_DESKTOP is set on the gateway the app spawns.""" + return env_var_enabled("HERMES_DESKTOP") + + +OPEN_PREVIEW_SCHEMA = { + "name": "open_preview", + "description": ( + "Open something in the preview pane beside the chat in the Hermes desktop " + "app. Use this when the user asks to see a page, dev server, or file in the " + "preview pane — e.g. \"open cnn.com in the preview pane\" or \"preview " + "localhost:3000\". Accepts a web URL (a bare domain like www.cnn.com is fine), " + "a localhost dev-server URL, or a file path (HTML renders live; other files " + "show their contents). The pane opens for the current window only." + ), + "parameters": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": ( + "What to preview: a web URL (https://… or a bare domain), a " + "localhost URL (localhost:3000), or a file path." + ), + }, + "label": { + "type": "string", + "description": "Optional tab label; defaults to the target's name.", + }, + }, + "required": ["url"], + }, +} + + +registry.register( + name="open_preview", + toolset="terminal", + schema=OPEN_PREVIEW_SCHEMA, + handler=lambda args, **kw: open_preview_tool(url=args.get("url", ""), label=args.get("label", "")), + check_fn=check_open_preview_requirements, + emoji="🖼️", +) diff --git a/toolsets.py b/toolsets.py index 1be62780d0ca..e72a1696b8b6 100644 --- a/toolsets.py +++ b/toolsets.py @@ -33,10 +33,10 @@ _HERMES_CORE_TOOLS = [ "web_search", "web_extract", # Terminal + process management "terminal", "process", - # Read the desktop GUI's embedded terminal pane, and close an agent's - # read-only terminal tab (both gated on HERMES_DESKTOP via check_fn — - # hidden outside the GUI). - "read_terminal", "close_terminal", + # Read the desktop GUI's embedded terminal pane, close an agent's read-only + # terminal tab, and open a URL/file in the preview pane (all gated on + # HERMES_DESKTOP via check_fn — hidden outside the GUI). + "read_terminal", "close_terminal", "open_preview", # File manipulation "read_file", "write_file", "patch", "search_files", # Vision + image generation diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 03d8492d932c..1886e31ccff7 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -10108,9 +10108,34 @@ def _wire_agent_terminal_output() -> None: process_registry.on_close = _emit_agent_terminal_close +_desktop_preview_wired = False + + +def _wire_desktop_preview() -> None: + """Bridge the desktop-only ``open_preview`` tool to a ``preview.open`` event. + + Idempotent. The tool reads ``HERMES_UI_SESSION_ID`` from the turn's context + and hands it back here as ``sid`` so the event routes to the window that + asked (``_emit``/``write_json`` is ``_stdout_lock``-guarded, so calling it + from the tool's thread is safe).""" + global _desktop_preview_wired + if _desktop_preview_wired: + return + try: + from tools import open_preview_tool + except Exception: + return + + open_preview_tool.set_preview_emitter( + lambda sid, url, label: _emit("preview.open", sid, {"url": url, "label": label}) + ) + _desktop_preview_wired = True + + def _start_notification_poller(sid: str, session: dict) -> threading.Event: """Start the background notification poller for a TUI session.""" _wire_agent_terminal_output() + _wire_desktop_preview() stop = threading.Event() t = threading.Thread( target=_notification_poller_loop, From e4877ba96e85f75d007e073b1a59326c3641b7f8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 11:24:58 -0500 Subject: [PATCH 014/238] flatten assistant message actions into an inline icon row Drop the kebab overflow so age, branch, copy, read aloud, and refresh are always one hover away. --- .../assistant-ui/thread/assistant-message.tsx | 78 +++++++++---------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx index e9ac8ad28a87..4c01d550f454 100644 --- a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx @@ -7,7 +7,7 @@ import { useMessageRuntime } from '@assistant-ui/react' import { useStore } from '@nanostores/react' -import { type FC, useCallback, useMemo, useState } from 'react' +import { type FC, useCallback, useMemo } from 'react' import { contentHasVisibleText, @@ -21,17 +21,11 @@ import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button import { PreviewAttachment } from '@/components/chat/preview-attachment' import { Codicon } from '@/components/ui/codicon' import { CopyButton } from '@/components/ui/copy-button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon, XIcon } from '@/lib/icons' import { extractPreviewTargets } from '@/lib/preview-targets' +import { relativeTime } from '@/lib/time' import { useEnterAnimation } from '@/lib/use-enter-animation' import { cn } from '@/lib/utils' import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback' @@ -144,12 +138,11 @@ export const AssistantMessage: FC<{ const AssistantActionBar: FC = ({ messageId, getMessageText, onBranchInNewChat }) => { const { t } = useI18n() const copy = t.assistant.thread - const [menuOpen, setMenuOpen] = useState(false) return (
= ({ messageId, getMessageText, // invisible by default (opacity-0 + pointer-events-none, reveals on // hover), so keeping it mounted reserves stable layout height with // no visual change during streaming. - 'relative flex flex-row items-center justify-end gap-2 py-1.5 opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100', - menuOpen && 'pointer-events-auto opacity-100 [&_button]:opacity-100' - )} + 'relative flex flex-row items-center justify-end gap-1.5 py-1.5 opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100' + } data-slot="aui_msg-actions" > + + { + triggerHaptic('selection') + onBranchInNewChat?.(messageId) + }} + tooltip={copy.branchNewChat} + > + + + triggerHaptic('submit')} tooltip={copy.refresh}> - - - - - - - e.preventDefault()} sideOffset={6}> - - onBranchInNewChat?.(messageId)}> - - {copy.branchNewChat} - - - -
) } -const ReadAloudItem: FC<{ getText: () => string; messageId: string }> = ({ getText, messageId }) => { +const ReadAloudButton: FC<{ getText: () => string; messageId: string }> = ({ getText, messageId }) => { const { t } = useI18n() const copy = t.assistant.thread const voicePlayback = useStore($voicePlayback) @@ -200,6 +188,7 @@ const ReadAloudItem: FC<{ getText: () => string; messageId: string }> = ({ getTe const isSpeaking = readAloudStatus === 'speaking' const anyPlaybackActive = voicePlayback.status !== 'idle' const Icon = isPreparing ? Loader2Icon : isSpeaking ? VolumeXIcon : Volume2Icon + const tooltip = isPreparing ? copy.preparingAudio : isSpeaking ? copy.stopReading : copy.readAloud const read = useCallback(async () => { const text = getText() @@ -216,29 +205,40 @@ const ReadAloudItem: FC<{ getText: () => string; messageId: string }> = ({ getTe }, [copy.readAloudFailed, getText, messageId]) return ( - { - e.preventDefault() + onClick={() => { + triggerHaptic('selection') void (isSpeaking ? stopVoicePlayback() : read()) }} + tooltip={tooltip} > - - {isPreparing ? copy.preparingAudio : isSpeaking ? copy.stopReading : copy.readAloud} - + + ) } -const MessageTimestamp: FC = () => { +const MessageAge: FC = () => { const { t } = useI18n() const createdAt = useAuiState(s => s.message.createdAt) - const label = formatMessageTimestamp(createdAt, t.assistant.thread) - if (!label) { + if (!createdAt) { return null } - return {label} + const date = createdAt instanceof Date ? createdAt : new Date(createdAt) + + if (Number.isNaN(date.getTime())) { + return null + } + + const absolute = formatMessageTimestamp(date, t.assistant.thread) + + return ( + + {relativeTime(date.getTime())} + + ) } const AssistantFooter: FC = props => ( From 34d0de80e64a47cb1022d99b964d3755a3c2bd3d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 02:23:39 -0400 Subject: [PATCH 015/238] feat(surfaces): route busy-input corrections through active-turn redirect The default `busy_input_mode: interrupt` now redirects the live turn instead of hard-stopping it and re-queuing a fresh turn, wired consistently across every first-party surface via the shared core primitive. - CLI, gateway (busy + PRIORITY paths), TUI (`_handle_busy_submit`), desktop (`session.redirect` RPC), and ACP call `redirect()` when the agent advertises `_supports_active_turn_redirect`, and fall back to the proven interrupt + next-turn queue for older runtimes. - Redirect is gated to plain text with no attachments: captioned or attachment-bearing events (including adapters that classify unknown media as `TEXT`) stay queued so media is never dropped. - ACP `cancel()` records the interrupted prompt, sets its cancel event, and hard-stops the agent while holding `runtime_lock`, closing the cancel-then-correct ordering gap; connection I/O happens after the lock is released. - Desktop appends the correction as a real user transcript message so the live view matches the durable history after reload. - `/busy` help, onboarding hints, and the new `session.redirect` RPC describe the redirect behavior; `/stop` remains the hard stop. --- acp_adapter/server.py | 98 +++++++++++++++---- agent/onboarding.py | 13 +++ .../composer/hooks/use-composer-submit.ts | 6 +- .../hooks/use-prompt-actions/index.test.tsx | 42 +++++--- .../session/hooks/use-prompt-actions/index.ts | 31 +++--- apps/desktop/src/app/types.ts | 5 + cli.py | 44 ++++++--- gateway/run.py | 65 +++++++++++- hermes_cli/cli_commands_mixin.py | 6 +- tests/acp_adapter/test_acp_commands.py | 62 ++++++++++++ tests/gateway/test_busy_session_ack.py | 48 +++++++++ tests/test_tui_gateway_queue_on_busy.py | 88 ++++++++++++++++- tests/test_tui_gateway_server.py | 29 ++++++ tui_gateway/server.py | 98 +++++++++++++++++-- ui-tui/src/app/useSubmission.ts | 19 ++-- 15 files changed, 563 insertions(+), 91 deletions(-) diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 266d587b0743..f664c47070e4 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -1218,12 +1218,19 @@ class HermesACPAgent(acp.Agent): with state.runtime_lock: if state.is_running and state.current_prompt_text: state.interrupted_prompt_text = state.current_prompt_text - state.cancel_event.set() - try: - if getattr(state, "agent", None) and hasattr(state.agent, "interrupt"): - state.agent.interrupt() - except Exception: - logger.debug("Failed to interrupt ACP session %s", session_id, exc_info=True) + # Publish cancellation and hard-stop the agent before another + # prompt can acquire this lock and mistake the turn for + # redirectable work. + state.cancel_event.set() + try: + if getattr(state, "agent", None) and hasattr(state.agent, "interrupt"): + state.agent.interrupt() + except Exception: + logger.debug( + "Failed to interrupt ACP session %s", + session_id, + exc_info=True, + ) logger.info("Cancelled session %s", session_id) async def fork_session( @@ -1352,6 +1359,26 @@ class HermesACPAgent(acp.Agent): elif rewrite_idle: user_text = steer_text user_content = steer_text + elif ( + text_only_prompt + and isinstance(user_content, str) + and not user_text.startswith("/") + ): + # Some ACP clients implement "stop and send" as two protocol calls: + # cancel the active prompt, then submit plain correction text. Keep + # the cancelled request attached so deictic follow-ups ("not that + # file") still have an explicit target. + interrupted_prompt = "" + with state.runtime_lock: + if not state.is_running and state.interrupted_prompt_text: + interrupted_prompt = state.interrupted_prompt_text + state.interrupted_prompt_text = "" + if interrupted_prompt: + user_text = ( + f"{interrupted_prompt}\n\n" + f"User correction/guidance after interrupt: {user_text}" + ) + user_content = user_text # Intercept slash commands — handle locally without calling the LLM. # Slash commands are text-only; if the client included images/resources, @@ -1366,23 +1393,54 @@ class HermesACPAgent(acp.Agent): await self._send_usage_update(state) return PromptResponse(stop_reason="end_turn") - # If Zed sends another regular prompt while the same ACP session is - # still running, queue it instead of racing two AIAgent loops against - # the same state.history. /steer and /queue are handled above and can - # land immediately. + # If the client sends another regular text prompt while this ACP session + # is running, route it through the core active-turn redirect. Rich media + # and older runtimes retain the proven next-turn queue fallback. + redirected = False + queued_depth: int | None = None with state.runtime_lock: if state.is_running: - queued_text = user_text or "[Image attachment]" - state.queued_prompts.append(queued_text) - depth = len(state.queued_prompts) - if self._conn: - update = acp.update_agent_message_text( - f"Queued for the next turn. ({depth} queued)" + if ( + text_only_prompt + and isinstance(user_content, str) + and getattr( + state.agent, + "_supports_active_turn_redirect", + False, ) - await self._conn.session_update(session_id, update) - return PromptResponse(stop_reason="end_turn") - state.is_running = True - state.current_prompt_text = user_text or "[Image attachment]" + is True + and hasattr(state.agent, "redirect") + ): + try: + redirected = bool(state.agent.redirect(user_content)) + except Exception: + logger.debug( + "ACP active-turn redirect failed for %s", + session_id, + exc_info=True, + ) + if not redirected: + queued_text = user_text or "[Image attachment]" + state.queued_prompts.append(queued_text) + queued_depth = len(state.queued_prompts) + else: + state.is_running = True + state.current_prompt_text = user_text or "[Image attachment]" + + if redirected: + if self._conn: + update = acp.update_agent_message_text( + "Redirected the active turn with your correction." + ) + await self._conn.session_update(session_id, update) + return PromptResponse(stop_reason="end_turn") + if queued_depth is not None: + if self._conn: + update = acp.update_agent_message_text( + f"Queued for the next turn. ({queued_depth} queued)" + ) + await self._conn.session_update(session_id, update) + return PromptResponse(stop_reason="end_turn") logger.info("Prompt on session %s: %s", session_id, user_text[:100]) diff --git a/agent/onboarding.py b/agent/onboarding.py index c29ea1529fb0..148fbcc9fdae 100644 --- a/agent/onboarding.py +++ b/agent/onboarding.py @@ -52,6 +52,13 @@ def busy_input_hint_gateway(mode: str) -> str: "Send `/busy interrupt` or `/busy queue` to change this, or " "`/busy status` to check. This notice won't appear again." ) + if mode == "redirect": + return ( + "💡 First-time tip — I redirected the current run using your message. " + "Completed work stays in context, and `/stop` still cancels the task. " + "Send `/busy queue` to wait for a separate turn, or `/busy status` " + "to check. This notice won't appear again." + ) return ( "💡 First-time tip — I just interrupted my current task to answer you. " "Send `/busy queue` to queue follow-ups for after the current task instead, " @@ -74,6 +81,12 @@ def busy_input_hint_cli(mode: str) -> str: "after the next tool call. Use /busy interrupt or /busy queue to " "change this. This tip only shows once." ) + if mode == "redirect": + return ( + "(tip) Your correction redirected the current run without discarding " + "completed work. Use /stop to cancel or /busy queue to wait for a " + "separate turn. This tip only shows once." + ) return ( "(tip) Your message interrupted the current run. " "Use /busy queue to queue messages for the next turn instead, " diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts index adf44e34a8d7..01ff0ecf8e4b 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.ts @@ -173,9 +173,9 @@ export function useComposerSubmit({ focusInput() } - // Steer the live turn (nudge without interrupting). Clears the draft up front - // for snappy feedback; if the gateway rejects (no live tool window) the words - // are re-queued so nothing is lost — same safety net as a plain queue. + // Redirect the live turn with a correction. The gateway either restarts the + // active model request with its displayed context or waits for the current + // tool boundary. If the turn already ended, queue the words instead. const steerDraft = () => { if (!onSteer || !canSteer) { return diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index a06ee1294c08..6fb71e1b6c35 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -61,6 +61,8 @@ interface HarnessHandle { activeSessionIdRef: MutableRefObject cancelRun: () => Promise restoreToMessage: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise + redirectPrompt: (text: string) => Promise + /** @deprecated Use `redirectPrompt`. */ steerPrompt: (text: string) => Promise submitText: (text: string, options?: SubmitTextOptions) => Promise } @@ -160,6 +162,8 @@ function Harness({ act(async () => actions.cancelRun(...args)) as Promise, restoreToMessage: (...args: Parameters) => act(async () => actions.restoreToMessage(...args)) as Promise, + redirectPrompt: (...args: Parameters) => + act(async () => actions.redirectPrompt(...args)) as Promise, steerPrompt: (...args: Parameters) => act(async () => actions.steerPrompt(...args)) as Promise, submitText: (...args: Parameters) => @@ -168,6 +172,7 @@ function Harness({ }, [ actions.cancelRun, actions.restoreToMessage, + actions.redirectPrompt, actions.steerPrompt, actions.submitText, activeSessionIdRef, @@ -703,32 +708,41 @@ describe('usePromptActions submit / queue drain semantics', () => { }) }) -describe('usePromptActions steerPrompt', () => { +describe('usePromptActions redirectPrompt', () => { afterEach(() => { cleanup() vi.restoreAllMocks() }) - it('injects the trimmed text via session.steer and reports acceptance on a queued status', async () => { - const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never) + it('redirects the live turn with trimmed correction text', async () => { + const requestGateway = vi.fn(async () => ({ status: 'redirected' }) as never) let handle: HarnessHandle | null = null + const capturedStates: Record[] = [] await actRender( - (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> + (handle = h)} + onSeedState={state => capturedStates.push(state)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + /> ) - const accepted = await handle!.steerPrompt(' nudge the run ') + const accepted = await handle!.redirectPrompt(' nudge the run ') expect(accepted).toBe(true) - // Steer never starts a turn — it rides the live run via session.steer only. - expect(requestGateway).toHaveBeenCalledWith('session.steer', { + expect(requestGateway).toHaveBeenCalledWith('session.redirect', { session_id: RUNTIME_SESSION_ID, text: 'nudge the run' }) expect(requestGateway).not.toHaveBeenCalledWith('prompt.submit', expect.anything()) + expect((capturedStates.at(-1)?.messages as unknown[]).at(-1)).toMatchObject({ + role: 'user', + parts: [{ type: 'text', text: 'nudge the run' }] + }) }) - it('reports rejection (so the caller queues) when the gateway has no live tool window', async () => { + it('reports rejection so the caller queues when the turn already ended', async () => { const requestGateway = vi.fn(async () => ({ status: 'rejected' }) as never) let handle: HarnessHandle | null = null @@ -736,12 +750,12 @@ describe('usePromptActions steerPrompt', () => { (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) - expect(await handle!.steerPrompt('too late')).toBe(false) + expect(await handle!.redirectPrompt('too late')).toBe(false) }) - it('reports rejection (never throws) when the steer RPC errors', async () => { + it('reports rejection without throwing when the redirect RPC errors', async () => { const requestGateway = vi.fn(async () => { - throw new Error('agent does not support steer') + throw new Error('agent does not support redirect') }) let handle: HarnessHandle | null = null @@ -749,18 +763,18 @@ describe('usePromptActions steerPrompt', () => { (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) - expect(await handle!.steerPrompt('boom')).toBe(false) + expect(await handle!.redirectPrompt('boom')).toBe(false) }) it('skips the RPC entirely for empty text', async () => { - const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never) + const requestGateway = vi.fn(async () => ({ status: 'redirected' }) as never) let handle: HarnessHandle | null = null await actRender( (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) - expect(await handle!.steerPrompt(' ')).toBe(false) + expect(await handle!.redirectPrompt(' ')).toBe(false) expect(requestGateway).not.toHaveBeenCalled() }) }) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 1fde9070d4a3..852e822b1896 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -41,7 +41,7 @@ import type { HandoffRequestResponse, HandoffStateResponse, ImageAttachResponse, - SessionSteerResponse + SessionRedirectResponse } from '../../../types' import { @@ -599,11 +599,11 @@ export function usePromptActions({ } }, [activeSessionIdRef, busyRef, copy.stopFailed, requestGateway, selectedStoredSessionIdRef, updateSessionState]) - // Steer = nudge the live turn without interrupting: the gateway appends the - // text to the next tool result so the model reads it on its next iteration - // (desktop parity with `/steer`). Returns false on reject (no live tool - // window) so the caller can fall back to queueing the words for the next turn. - const steerPrompt = useCallback( + // The desktop steering action is an immediate correction: the core cancels + // model generation and rebuilds the live turn with displayed reasoning and + // completed work intact. During a tool it waits for the safe result boundary. + // Returns false when the turn raced to completion so the composer can queue. + const redirectPrompt = useCallback( async (rawText: string): Promise => { const text = sanitizeComposerInput(rawText).trim() const sessionId = activeSessionId || activeSessionIdRef.current @@ -613,14 +613,17 @@ export function usePromptActions({ } try { - const result = await requestGateway('session.steer', { session_id: sessionId, text }) + const result = await requestGateway('session.redirect', { + session_id: sessionId, + text + }) - if (result?.status === 'queued') { + if (result?.status === 'redirected') { triggerHaptic('submit') - // Inline note (not a toast) so the nudge lives in the transcript next - // to the turn it steered. The `steer:` prefix is rendered as a codicon - // row by SystemMessage (see STEER_NOTE_RE), same style as slash output. - appendSessionTextMessage(sessionId, 'system', `steer:${text}`) + // Match the durable core transcript: the correction is a real user + // message after the interrupted assistant checkpoint, not a system + // note that changes role after reload. + appendSessionTextMessage(sessionId, 'user', text) return true } @@ -806,7 +809,9 @@ export function usePromptActions({ handoffSession, reloadFromMessage, restoreToMessage, - steerPrompt, + redirectPrompt, + /** @deprecated Use `redirectPrompt` — this is an active-turn redirect, not tool steer. */ + steerPrompt: redirectPrompt, submitText, transcribeVoiceAudio } diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index 3f8da4144334..a9d09165c763 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -60,6 +60,11 @@ export interface SessionSteerResponse { text?: string } +export interface SessionRedirectResponse { + status?: 'redirected' | 'rejected' + text?: string +} + export interface SessionTitleResponse { title?: string // True when the session row isn't persisted yet and the title was queued diff --git a/cli.py b/cli.py index cac922c850b0..b9641e53ee20 100644 --- a/cli.py +++ b/cli.py @@ -3816,7 +3816,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): enabled=CLI_CONFIG["display"].get("persistent_output", True), max_lines=CLI_CONFIG["display"].get("persistent_output_max_lines", 200), ) - # busy_input_mode: "interrupt" (Enter interrupts current run), + # busy_input_mode: "interrupt" (Enter redirects current run), # "queue" (Enter queues for next turn), or "steer" (Enter injects # mid-run via /steer, arriving after the next tool call). _bim = str(CLI_CONFIG["display"].get("busy_input_mode", "interrupt")).strip().lower() @@ -13479,6 +13479,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): payload = (text, images) if images else text if self._agent_running and not (text and _looks_like_slash_command(text)): _effective_mode = self.busy_input_mode + redirected = False if _effective_mode == "steer": # Route Enter through /steer — inject mid-run after the # next tool call. Images can't ride along (steer only @@ -13506,15 +13507,35 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): preview = text if text else f"[{len(images)} image{'s' if len(images) != 1 else ''} attached]" _cprint(f" Queued for the next turn: {preview[:80]}{'...' if len(preview) > 80 else ''}") elif _effective_mode == "interrupt": - self._interrupt_queue.put(payload) - # Debug: log to file when message enters interrupt queue - try: - _dbg = _hermes_home / "interrupt_debug.log" - with open(_dbg, "a", encoding="utf-8") as _f: - _f.write(f"{time.strftime('%H:%M:%S')} ENTER: queued interrupt msg={str(payload)[:60]!r}, " - f"agent_running={self._agent_running}\n") - except Exception: - pass + if not images and text: + try: + if ( + self.agent is not None + and getattr( + self.agent, + "_supports_active_turn_redirect", + False, + ) + is True + and hasattr(self.agent, "redirect") + ): + redirected = bool(self.agent.redirect(text)) + except Exception: + redirected = False + if redirected: + preview = text[:80] + ("..." if len(text) > 80 else "") + _cprint(f" {_ACCENT}↪ Redirected current turn: '{preview}'{_RST}") + else: + # Compatibility path for older agents, multimodal + # follow-ups, or a turn that finished in the race. + self._interrupt_queue.put(payload) + try: + _dbg = _hermes_home / "interrupt_debug.log" + with open(_dbg, "a", encoding="utf-8") as _f: + _f.write(f"{time.strftime('%H:%M:%S')} ENTER: queued interrupt msg={str(payload)[:60]!r}, " + f"agent_running={self._agent_running}\n") + except Exception: + pass # First-touch onboarding: on the very first busy-while-running # event for this install, print a one-line tip explaining the # /busy knob. Flag persists to config.yaml and never fires @@ -13528,7 +13549,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): mark_seen, ) if not is_seen(CLI_CONFIG, BUSY_INPUT_FLAG): - _cprint(f" {_DIM}{busy_input_hint_cli(self.busy_input_mode)}{_RST}") + _hint_mode = "redirect" if redirected else _effective_mode + _cprint(f" {_DIM}{busy_input_hint_cli(_hint_mode)}{_RST}") mark_seen(_hermes_home / "config.yaml", BUSY_INPUT_FLAG) CLI_CONFIG.setdefault("onboarding", {}).setdefault("seen", {})[BUSY_INPUT_FLAG] = True except Exception: diff --git a/gateway/run.py b/gateway/run.py index 42dd61e02776..7653ffb1e9b8 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6030,10 +6030,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ) effective_mode = "queue" steered = False + redirected = False if effective_mode == "steer": steer_text = (event.text or "").strip() can_steer = ( steer_text + and event.message_type == MessageType.TEXT + and not event.media_urls + and not event.media_types and running_agent is not None and running_agent is not _AGENT_PENDING_SENTINEL and hasattr(running_agent, "steer") @@ -6047,6 +6051,21 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if not steered: # Fall back to queue (merge into pending messages, no interrupt) effective_mode = "queue" + elif ( + effective_mode == "interrupt" + and event.message_type == MessageType.TEXT + and not event.media_urls + and not event.media_types + and running_agent is not None + and running_agent is not _AGENT_PENDING_SENTINEL + and getattr(running_agent, "_supports_active_turn_redirect", False) is True + and hasattr(running_agent, "redirect") + ): + try: + redirected = bool(running_agent.redirect((event.text or "").strip())) + except Exception as exc: + logger.warning("Gateway redirect failed for session %s: %s", session_key, exc) + redirected = False # Store the message so it's processed as the next turn after the # current run finishes (or is interrupted). Skip this for a @@ -6063,16 +6082,22 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # turn (#43066 sub-bug 2). The FIFO path gives each text its own # turn in arrival order while still preserving photo-burst / album # merge semantics for media. - if not steered: + if not steered and not redirected: self._queue_or_replace_pending_event(session_key, event) is_queue_mode = effective_mode == "queue" is_steer_mode = effective_mode == "steer" + is_redirect_mode = effective_mode == "interrupt" and redirected # If not in queue/steer mode, interrupt the running agent immediately. # This aborts in-flight tool calls and causes the agent loop to exit # at the next check point. - if effective_mode == "interrupt" and running_agent and running_agent is not _AGENT_PENDING_SENTINEL: + if ( + effective_mode == "interrupt" + and not redirected + and running_agent + and running_agent is not _AGENT_PENDING_SENTINEL + ): try: _interrupt_text = event.text _media_urls = getattr(event, "media_urls", None) or [] @@ -6170,6 +6195,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew f"⏩ Steered into current run{status_detail}. " f"Your message arrives after the next tool call." ) + elif is_redirect_mode: + message = ( + f"↪ Redirected current run{status_detail}. " + f"I'll adjust using your correction." + ) elif is_queue_mode and demoted_for_subagents: # #30170 — explain the demotion so the user knows their # follow-up didn't accidentally kill the subagent and @@ -6211,6 +6241,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew _hint_mode = "steer" elif is_queue_mode: _hint_mode = "queue" + elif is_redirect_mode: + _hint_mode = "redirect" else: _hint_mode = "interrupt" message = ( @@ -10952,7 +10984,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew # is empty, the agent lacks steer(), or steer() rejects. steer_text = (event.text or "").strip() steered = False - if steer_text and hasattr(running_agent, "steer"): + if ( + event.message_type == MessageType.TEXT + and not event.media_urls + and not event.media_types + and steer_text + and hasattr(running_agent, "steer") + ): try: steered = bool(running_agent.steer(steer_text)) except Exception as exc: @@ -10996,6 +11034,27 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ) self._queue_or_replace_pending_event(_quick_key, event) return None + # Text-only corrections redirect the live turn (preserving + # displayed context) when the runtime supports it; media/voice and + # older runtimes fall back to the proven interrupt path below. + if ( + event.message_type == MessageType.TEXT + and not event.media_urls + and not event.media_types + and getattr(running_agent, "_supports_active_turn_redirect", False) + is True + and hasattr(running_agent, "redirect") + ): + try: + if running_agent.redirect((event.text or "").strip()): + logger.debug("PRIORITY redirect for session %s", _quick_key) + return None + except Exception as exc: + logger.warning( + "PRIORITY redirect failed for session %s: %s", + _quick_key, + exc, + ) logger.debug("PRIORITY interrupt for session %s", _quick_key) _interrupt_text = event.text _media_urls = getattr(event, "media_urls", None) or [] diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 4c98e70dbc66..f28c07602312 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -2620,7 +2620,7 @@ class CLICommandsMixin: /busy status Show current busy input mode /busy queue Queue input for the next turn instead of interrupting /busy steer Inject Enter mid-run via /steer (after next tool call) - /busy interrupt Interrupt the current run on Enter (default) + /busy interrupt Redirect the current run on Enter (default) """ from cli import _ACCENT, _DIM, _RST, _cprint, save_config_value parts = cmd.strip().split(maxsplit=1) @@ -2631,7 +2631,7 @@ class CLICommandsMixin: elif self.busy_input_mode == "steer": _behavior = "steers into current run (after next tool call)" else: - _behavior = "interrupts current run" + _behavior = "redirects current run immediately" _cprint(f" {_DIM}Enter while busy: {_behavior}{_RST}") _cprint(f" {_DIM}Usage: /busy [queue|steer|interrupt|status]{_RST}") return @@ -2649,7 +2649,7 @@ class CLICommandsMixin: elif arg == "steer": behavior = "Enter will steer your message into the current run (after the next tool call)." else: - behavior = "Enter will interrupt the current run while Hermes is busy." + behavior = "Enter will redirect the current run while Hermes is busy; /stop still cancels it." _cprint(f" {_ACCENT}✓ Busy input mode set to '{arg}' (saved to config){_RST}") _cprint(f" {_DIM}{behavior}{_RST}") else: diff --git a/tests/acp_adapter/test_acp_commands.py b/tests/acp_adapter/test_acp_commands.py index 4a95367a6ba5..4f8ca69ed85e 100644 --- a/tests/acp_adapter/test_acp_commands.py +++ b/tests/acp_adapter/test_acp_commands.py @@ -16,13 +16,19 @@ class FakeAgent: self.disabled_toolsets = [] self.tools = [] self.valid_tool_names = set() + self._supports_active_turn_redirect = True self.steers = [] + self.redirects = [] self.runs = [] def steer(self, text): self.steers.append(text) return True + def redirect(self, text): + self.redirects.append(text) + return True + def run_conversation(self, *, user_message, conversation_history, task_id, **kwargs): self.runs.append(user_message) messages = list(conversation_history or []) @@ -147,6 +153,62 @@ async def test_acp_steer_after_zed_interrupt_replays_interrupted_prompt_with_gui assert state.interrupted_prompt_text == "" +@pytest.mark.asyncio +async def test_acp_plain_correction_redirects_running_turn(): + acp_agent, state, fake, _conn = make_agent_and_state() + state.is_running = True + + response = await acp_agent.prompt( + session_id=state.session_id, + prompt=[TextContentBlock(type="text", text="No, use Postgres instead")], + ) + + assert response.stop_reason == "end_turn" + assert fake.redirects == ["No, use Postgres instead"] + assert state.queued_prompts == [] + assert fake.runs == [] + + +@pytest.mark.asyncio +async def test_acp_plain_correction_after_cancel_replays_original_prompt(): + acp_agent, state, fake, _conn = make_agent_and_state() + state.interrupted_prompt_text = "implement it with SQLite" + + response = await acp_agent.prompt( + session_id=state.session_id, + prompt=[TextContentBlock(type="text", text="No, use Postgres instead")], + ) + + assert response.stop_reason == "end_turn" + assert fake.runs == [ + "implement it with SQLite\n\n" + "User correction/guidance after interrupt: No, use Postgres instead" + ] + assert state.interrupted_prompt_text == "" + + +@pytest.mark.asyncio +async def test_acp_cancel_publishes_hard_stop_while_holding_runtime_lock(): + acp_agent, state, fake, _conn = make_agent_and_state() + state.is_running = True + state.current_prompt_text = "original request" + observed = {} + + def interrupt(): + acquired = state.runtime_lock.acquire(blocking=False) + observed["lock_held"] = not acquired + if acquired: + state.runtime_lock.release() + + fake.interrupt = interrupt + + await acp_agent.cancel(state.session_id) + + assert observed["lock_held"] is True + assert state.cancel_event.is_set() + assert state.interrupted_prompt_text == "original request" + + @pytest.mark.asyncio async def test_acp_steer_on_idle_session_runs_as_regular_prompt(): # /steer on an idle session (no running turn, nothing to salvage) should diff --git a/tests/gateway/test_busy_session_ack.py b/tests/gateway/test_busy_session_ack.py index 66c4672f21c3..8b8598757992 100644 --- a/tests/gateway/test_busy_session_ack.py +++ b/tests/gateway/test_busy_session_ack.py @@ -211,6 +211,54 @@ class TestBusySessionAck: # Verify agent interrupt was called agent.interrupt.assert_called_once_with("Are you working?") + @pytest.mark.asyncio + async def test_interrupt_mode_redirects_capable_core_agent(self): + runner, _sentinel = _make_runner() + runner._busy_input_mode = "interrupt" + adapter = _make_adapter() + event = _make_event(text="No, use Postgres") + sk = build_session_key(event.source) + + agent = MagicMock() + agent._supports_active_turn_redirect = True + agent.redirect.return_value = True + agent._active_children = [] + agent.get_activity_summary.return_value = {} + runner._running_agents[sk] = agent + runner.adapters[event.source.platform] = adapter + + assert await runner._handle_active_session_busy_message(event, sk) is True + + agent.redirect.assert_called_once_with("No, use Postgres") + agent.interrupt.assert_not_called() + assert sk not in adapter._pending_messages + content = adapter._send_with_retry.call_args.kwargs.get("content", "") + assert "Redirected current run" in content + + @pytest.mark.asyncio + async def test_text_event_with_attachment_is_queued_not_redirected(self): + runner, _sentinel = _make_runner() + runner._busy_input_mode = "interrupt" + adapter = _make_adapter() + event = _make_event(text="use this attachment") + # QQBot and other adapters may retain unknown attachment MIME types on + # a TEXT event, so message_type alone is not a safe redirect gate. + event.media_urls = ["https://example.invalid/attachment.bin"] + event.media_types = ["application/octet-stream"] + sk = build_session_key(event.source) + + agent = MagicMock() + agent._supports_active_turn_redirect = True + agent._active_children = [] + runner._running_agents[sk] = agent + runner.adapters[event.source.platform] = adapter + + assert await runner._handle_active_session_busy_message(event, sk) is True + + agent.redirect.assert_not_called() + assert adapter._pending_messages[sk] is event + assert adapter._pending_messages[sk].media_urls == event.media_urls + @pytest.mark.asyncio async def test_queue_mode_suppresses_interrupt_and_updates_ack(self): """When busy_input_mode is 'queue', message is queued WITHOUT interrupt.""" diff --git a/tests/test_tui_gateway_queue_on_busy.py b/tests/test_tui_gateway_queue_on_busy.py index 804a3b5505cf..0543d7e8b6b2 100644 --- a/tests/test_tui_gateway_queue_on_busy.py +++ b/tests/test_tui_gateway_queue_on_busy.py @@ -1,12 +1,12 @@ -"""A prompt that lands mid-turn is interrupted + queued, never dropped. +"""A prompt that lands mid-turn is redirected or queued, never dropped. Before this, ``prompt.submit`` on a running session returned ``session busy``, forcing clients into a deadline-bounded busy-retry. When turn teardown outlived the deadline — e.g. a slow, non-interruptible tool (``web_search``) still running when the user hit stop — the resubmitted message was silently dropped ("it just doesn't listen"). The gateway now applies the ``busy_input_mode`` -policy: interrupt the live turn (default) and queue the message to run as the -next turn, drained in ``run``'s tail. +policy: redirect the live turn by default, with the legacy interrupt + queue +path retained as a compatibility fallback. """ import threading @@ -49,7 +49,26 @@ def test_enqueue_merges_second_arrival_losslessly(): # ── _handle_busy_submit (policy) ─────────────────────────────────────────── -def test_busy_interrupt_mode_interrupts_and_queues(monkeypatch): +def test_busy_interrupt_mode_redirects_active_turn(monkeypatch): + monkeypatch.setattr(server, "_load_busy_input_mode", lambda: "interrupt") + seen = [] + agent = types.SimpleNamespace( + _supports_active_turn_redirect=True, + redirect=lambda text: seen.append(text) or True, + interrupt=lambda *a, **k: (_ for _ in ()).throw( + AssertionError("redirect must not hard-interrupt") + ), + ) + session = _session(agent=agent, running=True) + + resp = server._handle_busy_submit("r1", "sid", session, "redirect", "ws-1") + + assert resp["result"]["status"] == "redirected" + assert seen == ["redirect"] + assert session.get("queued_prompt") is None + + +def test_busy_interrupt_mode_falls_back_for_legacy_agent(monkeypatch): monkeypatch.setattr(server, "_load_busy_input_mode", lambda: "interrupt") calls = {"interrupt": 0} agent = types.SimpleNamespace(interrupt=lambda *a, **k: calls.__setitem__("interrupt", calls["interrupt"] + 1)) @@ -134,6 +153,67 @@ def test_busy_helper_retries_when_turn_finished(monkeypatch): assert session.get("queued_prompt") is None +def test_busy_interrupt_mode_normalizes_rich_text_before_redirect(monkeypatch): + monkeypatch.setattr(server, "_load_busy_input_mode", lambda: "interrupt") + seen = [] + agent = types.SimpleNamespace( + _supports_active_turn_redirect=True, + redirect=lambda text: seen.append(text) or True, + interrupt=lambda *a, **k: None, + ) + session = _session(agent=agent, running=True) + rich = [{"type": "text", "text": " redirect me "}] + + resp = server._handle_busy_submit( + "r1", + "sid", + session, + rich, + "ws-1", + ) + + assert resp["result"]["status"] == "redirected" + assert seen == ["redirect me"] + assert session.get("queued_prompt") is None + + +def test_busy_queue_fallback_preserves_original_structured_text(monkeypatch): + monkeypatch.setattr(server, "_load_busy_input_mode", lambda: "interrupt") + rich = [{"type": "text", "text": " keep me "}] + agent = types.SimpleNamespace( + _supports_active_turn_redirect=True, + redirect=lambda text: False, + interrupt=lambda *a, **k: None, + ) + session = _session(agent=agent, running=True) + + resp = server._handle_busy_submit("r1", "sid", session, rich, "ws-1") + + assert resp["result"]["status"] == "queued" + assert session["queued_prompt"]["text"] == rich + + +def test_busy_interrupt_mode_queues_multimodal_payload_instead_of_redirect(monkeypatch): + monkeypatch.setattr(server, "_load_busy_input_mode", lambda: "interrupt") + seen = [] + rich = [ + {"type": "text", "text": "caption"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}, + ] + agent = types.SimpleNamespace( + _supports_active_turn_redirect=True, + redirect=lambda text: seen.append(text) or True, + interrupt=lambda *a, **k: None, + ) + session = _session(agent=agent, running=True) + + resp = server._handle_busy_submit("r1", "sid", session, rich, "ws-1") + + assert resp["result"]["status"] == "queued" + assert seen == [] + assert session["queued_prompt"]["text"] == rich + + # ── _drain_queued_prompt ─────────────────────────────────────────────────── def test_drain_fires_queued_prompt_and_claims_running(monkeypatch): diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 125d3eb584e1..48fa63c7b821 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -6835,6 +6835,35 @@ def test_session_steer_errors_when_agent_has_no_steer_method(): assert resp["error"]["code"] == 4010 +def test_session_redirect_calls_capable_core_agent(monkeypatch): + calls = [] + agent = types.SimpleNamespace( + _supports_active_turn_redirect=True, + redirect=lambda text: calls.append(text) or True, + ) + session = _session(agent=agent) + server._sessions["sid"] = session + try: + before = session.get("last_active") + resp = server.handle_request( + { + "id": "1", + "method": "session.redirect", + "params": {"session_id": "sid", "text": "use Postgres"}, + } + ) + finally: + server._sessions.pop("sid", None) + + assert resp["result"] == { + "status": "redirected", + "text": "use Postgres", + } + assert calls == ["use Postgres"] + assert session.get("last_active") is not None + assert before is None or session["last_active"] >= before + + def test_session_info_includes_mcp_servers(monkeypatch): fake_status = [ {"name": "github", "transport": "http", "tools": 12, "connected": True}, diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 03d8492d932c..15ed6b4a7ad5 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5553,6 +5553,38 @@ def _coerce_message_text(content: Any) -> str: return str(content) +_TEXT_ONLY_BUSY_PART_KINDS = frozenset({"text", "input_text", "output_text"}) + + +def _is_text_only_busy_payload(content: Any) -> bool: + """True when a busy submit carries only plain text, not attachments/media.""" + if content is None: + return False + if isinstance(content, (str, int, float)): + return True + if isinstance(content, list): + if not content: + return False + for part in content: + if isinstance(part, str): + continue + if not isinstance(part, dict): + return False + kind = part.get("type") + if kind in _TEXT_ONLY_BUSY_PART_KINDS: + continue + if kind is None and isinstance(part.get("text"), str): + continue + return False + return True + if isinstance(content, dict): + kind = content.get("type") + if kind in _TEXT_ONLY_BUSY_PART_KINDS: + return True + return kind is None and isinstance(content.get("text"), str) + return False + + def _history_to_messages(history: list[dict]) -> list[dict]: messages = [] tool_call_args = {} @@ -5761,15 +5793,13 @@ def _handle_busy_submit( a turn is in flight, instead of rejecting it with ``session busy``. The old rejection forced clients into a deadline-bounded busy-retry that - silently dropped the send when turn teardown outlived the deadline (e.g. a - slow, non-interruptible tool like ``web_search`` running when the user hits - stop). The message is instead queued to run as the next turn — and, for the - default ``interrupt`` policy, the live turn is interrupted so it winds down - promptly. Drained in ``run``'s tail (see ``_run_prompt_submit``). + silently dropped the send when turn teardown outlived the deadline. The + default policy now redirects a capable core agent in place; older agents + retain the proven interrupt-and-queue path drained from ``run``'s tail. - Modes: ``interrupt`` (default) → interrupt + queue; ``queue`` → queue - without interrupting; ``steer`` → inject into the live turn if accepted, - else queue. + Modes: ``interrupt`` (default) → redirect the live turn, falling back to + hard interrupt + queue for older agents; ``queue`` → queue without + interrupting; ``steer`` → inject after the current atomic action. """ mode = _load_busy_input_mode() agent = session.get("agent") @@ -5778,14 +5808,34 @@ def _handle_busy_submit( # The turn ended between prompt.submit's first busy check and this # helper. Let the caller retry and claim the now-idle session. return None - if mode == "steer" and agent is not None and hasattr(agent, "steer"): + text_only = _is_text_only_busy_payload(text) + plain_text = _coerce_message_text(text).strip() if text_only else "" + if mode == "steer" and text_only and plain_text and agent is not None and hasattr(agent, "steer"): try: - if agent.steer(text): + if agent.steer(plain_text): with session["history_lock"]: session["last_active"] = time.time() return _ok(rid, {"status": "steered"}) except Exception: pass # fall through to queue + # Text-only corrections redirect the live turn in place when the runtime + # supports it; media/attachment payloads and older agents fall through to + # the proven interrupt + queue path below. + if ( + mode == "interrupt" + and text_only + and plain_text + and agent is not None + and getattr(agent, "_supports_active_turn_redirect", False) is True + and hasattr(agent, "redirect") + ): + try: + if agent.redirect(plain_text): + with session["history_lock"]: + session["last_active"] = time.time() + return _ok(rid, {"status": "redirected"}) + except Exception: + pass # preserve the proven interrupt + queue fallback below # Queue before asking the live turn to stop. In particular, never call a # provider or compute-host method while holding history_lock: an interrupt # can wait behind the very operation it is trying to cancel. @@ -9585,6 +9635,34 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"status": "queued" if accepted else "rejected", "text": text}) +@method("session.redirect") +def _(rid, params: dict) -> dict: + """Redirect the active model turn while preserving valid work/context.""" + text = (params.get("text") or "").strip() + if not text: + return _err(rid, 4002, "text is required") + session, err = _sess_nowait(params, rid) + if err: + return err + agent = session.get("agent") + if ( + agent is None + or getattr(agent, "_supports_active_turn_redirect", False) is not True + or not hasattr(agent, "redirect") + ): + return _err(rid, 4010, "agent does not support active-turn redirect") + try: + accepted = agent.redirect(text) + except Exception as exc: + return _err(rid, 5000, f"redirect failed: {exc}") + if accepted: + session["last_active"] = time.time() + return _ok( + rid, + {"status": "redirected" if accepted else "rejected", "text": text}, + ) + + @method("terminal.resize") def _(rid, params: dict) -> dict: session, err = _sess_nowait(params, rid) diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index a5c484cc288c..a70b1fd7390d 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -158,9 +158,9 @@ export function useSubmission(opts: UseSubmissionOptions) { // - 'steer' : inject into the current turn via session.steer; falls // back to queue when steer is rejected (no agent / no // tool window). - // - 'interrupt' (default): queue the text + interrupt with `keepBusy`; the - // busy→false settle edge drains it once (desktop parity). - // No optimistic send → no duplicate bubble / race note. + // - 'interrupt' (default): submit immediately; the backend redirects the + // active model request (or safely steers after a tool), + // with legacy interrupt + queue as its compatibility path. // // `opts.fallbackToFront` re-inserts at the queue head (queue-edit picks keep // their position); the mainline submit path appends. @@ -201,14 +201,13 @@ export function useSubmission(opts: UseSubmissionOptions) { return } - // 'interrupt': queue + interrupt(keepBusy); the settle edge drains it once. - enqueueText() - - if (live.sid) { - turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys }, { keepBusy: true }) - } + // The gateway owns the atomic redirect decision because it knows whether + // the agent is in model generation, tool execution, or an older runtime. + // Reuse the normal submit pipeline so the correction gets its user bubble + // and file-drop interpolation exactly once. + send(full) }, - [appendMessage, composerActions, composerRefs, gw, sys] + [composerActions, composerRefs, gw, send, sys] ) const dispatchSubmission = useCallback( From 79b047820f8e7e8527b3293981d9d0996b36dcab Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 02:23:39 -0400 Subject: [PATCH 016/238] docs: describe active-turn redirect busy-input behavior Update the CLI and messaging guides so the default `interrupt` mode reflects the new behavior: a follow-up redirects the active turn (preserving displayed reasoning and completed work, letting running tools finish at a safe boundary) rather than hard-stopping it, with `/stop` still the explicit hard stop. --- website/docs/user-guide/cli.md | 16 ++++++++-------- website/docs/user-guide/messaging/index.md | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index f786e5a42f91..ca10b145847d 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -241,14 +241,14 @@ Most terminals send the same byte sequence for `Enter` and `Shift+Enter` by defa Where the terminal cannot distinguish them, `Alt+Enter` and `Ctrl+J` continue to work everywhere. **On Windows Terminal specifically, `Alt+Enter` is captured by the terminal (toggles fullscreen) and never reaches Hermes — use `Ctrl+Enter` (delivered as `Ctrl+J`) or `Ctrl+J` directly for a newline.** -## Interrupting the Agent +## Redirecting the Agent Mid-Turn -You can interrupt the agent at any point: +While the agent is working, you can send a correction without starting a new turn: -- **Type a new message + Enter** while the agent is working — it interrupts and processes your new instructions +- **Type a new message + Enter** — redirects the active turn using your correction - **`Ctrl+C`** — interrupt the current operation (press twice within 2s to force exit) -- In-progress terminal commands are killed immediately (SIGTERM, then SIGKILL after 1s) -- Multiple messages typed during interrupt are combined into one prompt +- Completed tool work and reasoning already shown stay in context +- A running tool reaches its safe boundary before the correction is applied ### Busy Input Mode @@ -256,7 +256,7 @@ The `display.busy_input_mode` config key controls what happens when you press En | Mode | Behavior | |------|----------| -| `"interrupt"` (default) | Your message interrupts the current operation and is processed immediately | +| `"interrupt"` (default) | Your message redirects the active turn. Model generation restarts with displayed reasoning and completed work preserved; running tools finish first | | `"queue"` | Your message is silently queued and sent as the next turn after the agent finishes | | `"steer"` | Your message is injected into the current run via `/steer`, arriving at the agent after the next tool call — no interrupt, no new turn | @@ -266,7 +266,7 @@ display: busy_input_mode: "steer" # or "queue" or "interrupt" (default) ``` -`"queue"` mode is useful when you want to prepare follow-up messages without accidentally canceling in-flight work. `"steer"` mode is useful when you want to redirect the agent mid-task without interrupting — e.g. "actually, also check the tests" while it's still editing code. Unknown values fall back to `"interrupt"`. +`"queue"` mode prepares a separate follow-up turn. `"steer"` always waits for the next tool-result boundary. The default `"interrupt"` mode responds sooner during model generation while avoiding cancellation of a running tool. Use `/stop` when you want to cancel the turn and its foreground work. Unknown values fall back to `"interrupt"`. `"steer"` has two automatic fallbacks: if the agent hasn't started yet, or if images are attached, the message falls back to `"queue"` behavior so nothing is lost. @@ -280,7 +280,7 @@ You can also change it inside the CLI: ``` :::tip First-touch hint -The very first time you press Enter while Hermes is working, Hermes prints a one-line reminder explaining the `/busy` knob (`"(tip) Your message interrupted the current run…"`). It only fires once per install — a flag in `config.yaml` under `onboarding.seen.busy_input_prompt` latches it. Delete that key to see the tip again. +The first time you press Enter while Hermes is working, Hermes prints a one-line reminder explaining the `/busy` knob. It only fires once per install; `onboarding.seen.busy_input_prompt` in `config.yaml` records that it was shown. Delete that key to see the tip again. ::: ### Suspending to Background diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index 88f57010064b..da107c4ae613 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -352,18 +352,18 @@ gateway: Use `/whoami` from any platform to see the active scope, your tier (admin / user / unrestricted), and which slash commands you can run. See the [Telegram](/user-guide/messaging/telegram#slash-command-access-control) and [Discord](/user-guide/messaging/discord#slash-command-access-control) pages for platform-specific examples. -## Interrupting the Agent +## Redirecting the Agent -Send any message while the agent is working to interrupt it. Key behaviors: +Send a message while the agent is working to correct the active turn: -- **In-progress terminal commands are killed immediately** (SIGTERM, then SIGKILL after 1s) -- **Tool calls are cancelled** — only the currently-executing one runs, the rest are skipped -- **Multiple messages are combined** — messages sent during interruption are joined into one prompt -- **`/stop` command** — interrupts without queuing a follow-up message +- **Model generation restarts with context** — reasoning already shown and visible partial text are retained as an ordinary assistant checkpoint +- **Completed work stays available** — prior tool calls and results remain in the turn +- **Running tools finish safely** — the correction is applied at the next tool-result boundary instead of killing the tool +- **`/stop` remains a hard stop** — use it to cancel the active turn and foreground work ### Queue vs interrupt vs steer (busy-input mode) -By default, messaging a busy agent interrupts it. Two other modes are available: +By default, messaging a busy agent redirects its active turn. Two other modes are available: - `queue` — follow-up messages wait and run as the next turn after the current task finishes. - `steer` — follow-up messages are injected into the current run via `/steer`, arriving at the agent after the next tool call. No interrupt, no new turn. Falls back to `queue` behavior if the agent hasn't started yet. @@ -376,7 +376,7 @@ display: The first time you message a busy agent on any platform, Hermes appends a one-line reminder to the busy-ack explaining the knob (`"💡 First-time tip — …"`). The reminder fires once per install — a flag under `onboarding.seen.busy_input_prompt` latches it. Delete that key to see the tip again. -If you find the busy-ack noisy — especially with voice input or rapid-fire messages — set `display.busy_ack_enabled: false`. Your input is still queued/steered/interrupts as normal, only the chat reply is silenced. +If you find the busy acknowledgment noisy, set `display.busy_ack_enabled: false`. Input handling is unchanged; only the confirmation message is hidden. ## Tool Progress Notifications From 3d40a1cbf2421419ee011095678c423cbe22a0bf Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 17:51:50 -0400 Subject: [PATCH 017/238] feat(desktop): Cursor-style stop-and-correct on the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain Enter (and the primary send button) while a turn is running now redirects the live turn with the typed correction instead of queueing it — matching Cursor's stop-and-correct. Removes the now-redundant steering-wheel button (redirect is the default gesture) and teaches the primary button the `steer` action. Attachments still queue; slash commands still run inline. --- .../src/app/chat/composer/controls.tsx | 46 ++++--------------- .../composer/hooks/use-composer-submit.ts | 19 +++++--- apps/desktop/src/app/chat/composer/index.tsx | 8 ++-- 3 files changed, 25 insertions(+), 48 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 7852ee81ef56..314bbd4c89cc 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -1,11 +1,9 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' -import { KbdCombo } from '@/components/ui/kbd' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' -import { AudioLines, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons' -import { formatCombo } from '@/lib/keybinds/combo' +import { AudioLines, iconSize, Layers3, Loader2, Square, Volume2, VolumeX } from '@/lib/icons' import { cn } from '@/lib/utils' import type { ConversationStatus } from './hooks/use-voice-conversation' @@ -42,7 +40,6 @@ export function ComposerControls({ autoSpeak, busy, busyAction, - canSteer, canSubmit, compactModelPill = false, conversation, @@ -51,13 +48,11 @@ export function ComposerControls({ state, voiceStatus, onDictate, - onSteer, onToggleAutoSpeak }: { autoSpeak: boolean busy: boolean - busyAction: 'queue' | 'stop' - canSteer: boolean + busyAction: 'steer' | 'queue' | 'stop' canSubmit: boolean compactModelPill?: boolean conversation: ConversationProps @@ -66,49 +61,24 @@ export function ComposerControls({ state: ChatBarState voiceStatus: VoiceStatus onDictate: () => void - onSteer: () => void onToggleAutoSpeak: () => void }) { const { t } = useI18n() const c = t.composer - const steerCombo = formatCombo('mod+enter') - const steerLabel = `${c.steer} (${steerCombo})` - - const steerTip = ( - - {c.steer} - - - ) if (conversation.active) { return } const showVoicePrimary = !busy && !hasComposerPayload + // steer + stop both interrupt the live turn (the difference is whether a + // correction rides along), so they share the stop glyph; only queue differs. + const busyLabel = busyAction === 'queue' ? c.queueMessage : busyAction === 'steer' ? c.steer : c.stop return (
- {/* While the agent runs and the user is typing, steer takes over the mic's - slot rather than crowding the row with an extra button. */} - {canSteer ? ( - - - - ) : ( - - )} + {showVoicePrimary ? ( @@ -127,9 +97,9 @@ export function ComposerControls({ ) : ( - + - -
- ) -} - +// DEV-only preview switcher: swaps the whole page onto a canned fixture so every +// billing state can be reviewed without a matching live account. Marked with a +// wrench + "preview" so it never reads as a shipping control (it's compiled out of +// production builds entirely). function BillingFixtureSelect({ onValueChange, value @@ -406,23 +375,27 @@ function BillingFixtureSelect({ value: BillingFixtureSelection }) { return ( - +
+ + preview + +
) } @@ -464,15 +437,16 @@ function BillingSettingsContent({ const subscriptionResult = subscriptionState.data const view = deriveBillingView(billingResult, subscriptionResult) const billing = billingResult?.ok ? billingResult.data : undefined - const usageUpdatedAt = oldestUpdatedAt(billingState.dataUpdatedAt, subscriptionState.dataUpdatedAt) - const usageIsFetching = billingState.isFetching || subscriptionState.isFetching - - const refreshUsage = () => { - void Promise.all([billingState.refetch(), subscriptionState.refetch()]) - } const { paymentRow, refillRow, topupRow } = view + // The Payment & credits card groups every money control (payment method, + // one-time top-up, auto-refill) into a single divide-y list instead of three + // free-floating one-row sections. + const accountRows = [paymentRow, topupRow, refillRow].filter( + (row): row is BillingAccountRowView => row !== undefined + ) + // Gate the plans sub-view on the SAME capability that renders the in-app button // (`plan.action`): a team / non-changer deep-linking `bview=plans` must never // reach a grid of live Choose buttons — it falls back to the overview. @@ -491,59 +465,42 @@ function BillingSettingsContent({ -
-
+ {view.notice && } + +
+ {view.summary.map(item => ( ))} -
+
- {view.notice && } - {view.plan && ( -
- - setSubView('plans')} plan={view.plan} /> -
+ + + setSubView('plans')} plan={view.plan} /> + + )} - {paymentRow && ( -
- - -
- )} - - {topupRow && ( -
- - -
- )} - - {refillRow && ( -
- - -
+ {accountRows.length > 0 && ( + + + {accountRows.map(row => ( + + ))} + + )} {view.usageRows.length > 0 && ( - <> - -
+ + {view.usageRows.map(row => ( ))} - -
- + + )} { @@ -586,9 +543,3 @@ export function BillingSettings() { return } - -function oldestUpdatedAt(...timestamps: number[]): number { - const populated = timestamps.filter(timestamp => timestamp > 0) - - return populated.length > 0 ? Math.min(...populated) : Date.now() -} diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.ts b/apps/desktop/src/app/settings/billing/use-billing-state.ts index da02f441ebe0..97ccf0cccc7b 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts @@ -11,7 +11,12 @@ export const EMPTY_BILLING_VALUE = '—' export const FALLBACK_PORTAL_BILLING_URL = 'https://portal.nousresearch.com/billing' export const FALLBACK_PORTAL_URL = 'https://portal.nousresearch.com' +// Billing polls on its own while the page is mounted (react-query only ticks an +// active observer), so the view stays live without a manual refresh control — +// matching every other data view in the app. It pauses when the window is +// backgrounded (refetchIntervalInBackground defaults to false). const BILLING_QUERY_OPTIONS = { + refetchInterval: 30_000, refetchOnWindowFocus: true, retry: false, staleTime: 30_000 @@ -30,6 +35,8 @@ export interface BillingNoticeView { } message: string title: string + /** `warn` = an actionable blocker (e.g. no card); `info` = neutral guidance. */ + tone?: 'info' | 'warn' } export interface BillingRowActionView { @@ -207,7 +214,7 @@ export function deriveBillingView( const tiers = derivePlanTiers(subscription, billing.portal_url, capable, pending) return { - notice: undefined, + notice: noCardNotice(billing), paymentRow: paymentMethodRow(billing), plan: derivePlanCard(billing, subscription, subscriptionResult, tiers, capable, pending), refillRow: autoReloadRow(billing), @@ -276,26 +283,6 @@ export function formatBillingDate(value?: null | string): string { return fmtDate.format(date) } -export function formatUsageUpdatedAgo(updatedAt: number, now: number): string { - const elapsedSeconds = Math.max(0, Math.floor((now - updatedAt) / 1000)) - - if (elapsedSeconds < 1) { - return 'just now' - } - - if (elapsedSeconds < 60) { - return `${elapsedSeconds}s ago` - } - - const elapsedMinutes = Math.floor(elapsedSeconds / 60) - - if (elapsedMinutes < 60) { - return `${elapsedMinutes}m ago` - } - - return `${Math.floor(elapsedMinutes / 60)}h ago` -} - function emptySummary(): BillingSummaryItemView[] { return [ { label: 'Balance', value: EMPTY_BILLING_VALUE }, @@ -311,7 +298,24 @@ function refusalNotice(refusal: BillingRefusal): BillingNoticeView { return { action: portalUrl ? { label: 'Open portal ↗', url: portalUrl } : undefined, message: resolved.message, - title: resolved.title + title: resolved.title, + tone: 'warn' + } +} + +// A logged-in account with no card can't buy credits or manage auto-refill, and +// every one of those controls disables silently — so lead the page with a single +// warn banner that names the blocker and links straight to the fix. +function noCardNotice(billing: BillingStateResponse): BillingNoticeView | undefined { + if (billing.card) { + return undefined + } + + return { + action: { label: 'Add card ↗', url: billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL }, + message: 'Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.', + title: 'No payment method on file', + tone: 'warn' } } diff --git a/apps/desktop/src/app/settings/primitives.tsx b/apps/desktop/src/app/settings/primitives.tsx index 4e55a2f9f4b9..45d48c73fcff 100644 --- a/apps/desktop/src/app/settings/primitives.tsx +++ b/apps/desktop/src/app/settings/primitives.tsx @@ -38,6 +38,35 @@ export function SectionHeading({ icon: Icon, title, meta }: { icon: IconComponen ) } +// The canonical settings surface: a soft-bordered muted well. Callers own the +// inner padding (a `divide-y` list wants none; a single block wants `p-4`) so the +// one container styling stays consistent across every settings page. +export function SettingsCard({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
+} + +// A titled section: heading + body with the shared vertical rhythm. Keeps the +// heading and its content welded together so pages stop hand-rolling +// `
` at every call site. +export function SettingsSection({ + children, + icon, + meta, + title +}: { + children: ReactNode + icon: IconComponent + meta?: string + title: string +}) { + return ( +
+ + {children} +
+ ) +} + export function NavLink({ icon: Icon, label, diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index e66a433b30ec..1fda821b796e 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -34,6 +34,7 @@ import { IconCopy as Copy, IconCopy as CopyIcon, IconCpu as Cpu, + IconCreditCard as CreditCard, IconDownload as Download, IconEgg as Egg, IconExternalLink as ExternalLink, @@ -155,6 +156,7 @@ export { Copy, CopyIcon, Cpu, + CreditCard, Download, Egg, ExternalLink, From 1b8e34e9456337955cb90a4e29afbaf7785189d1 Mon Sep 17 00:00:00 2001 From: "hermes-seaeye[bot]" <307254004+hermes-seaeye[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:56:16 +0000 Subject: [PATCH 043/238] fmt(js): `npm run fix` on merge (#69644) Co-authored-by: github-actions[bot] --- .../app/session/hooks/use-prompt-actions/index.test.tsx | 4 +++- .../src/app/session/hooks/use-prompt-actions/slash.ts | 8 +++++++- .../src/app/session/hooks/use-prompt-actions/utils.ts | 4 +++- .../src/app/session/hooks/use-session-state-cache.ts | 3 ++- apps/desktop/src/components/model-picker.tsx | 8 ++------ apps/desktop/src/lib/desktop-slash-commands.ts | 7 ++++++- 6 files changed, 23 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index cac5495fbfcb..13e4b6661ae5 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -469,7 +469,9 @@ describe('usePromptActions /compress', () => { ]) ) expect($notifications.get()).not.toEqual( - expect.arrayContaining([expect.objectContaining({ kind: 'success', message: expect.stringContaining('Compression aborted') })]) + expect.arrayContaining([ + expect.objectContaining({ kind: 'success', message: expect.stringContaining('Compression aborted') }) + ]) ) }) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts index 78bc00077914..0edbea218b22 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts @@ -432,7 +432,13 @@ export function useSlashCommand(deps: SlashCommandDeps) { // system message would look like a successful state change. renderSlashOutput(lines.join('\n')) } - notify({ durationMs: 5_000, id: noticeId, kind: aborted ? 'error' : 'success', message: lines.join('\n') }) + + notify({ + durationMs: 5_000, + id: noticeId, + kind: aborted ? 'error' : 'success', + message: lines.join('\n') + }) return } diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts index 3da0f9c983be..42598fb54627 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/utils.ts @@ -248,7 +248,9 @@ export function renderRpcResult(response: unknown, name: string): string { // process.stop — { killed: number } if ('killed' in r && typeof r.killed === 'number') { - return r.killed > 0 ? `Stopped ${r.killed} background process${r.killed === 1 ? '' : 'es'}.` : 'No background processes to stop.' + return r.killed > 0 + ? `Stopped ${r.killed} background process${r.killed === 1 ? '' : 'es'}.` + : 'No background processes to stop.' } // session.save — { file } diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts index 298eecf081c3..7e0d7278ed77 100644 --- a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts +++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts @@ -19,9 +19,10 @@ import { } from '@/store/session' import { publishSessionState } from '@/store/session-states' -import { chatMessageArraysEquivalent } from './use-session-actions/utils' import type { ClientSessionState } from '../../types' +import { chatMessageArraysEquivalent } from './use-session-actions/utils' + interface SessionStateCacheOptions { activeSessionId: string | null busyRef: MutableRefObject diff --git a/apps/desktop/src/components/model-picker.tsx b/apps/desktop/src/components/model-picker.tsx index 33028073522d..73e3401e078f 100644 --- a/apps/desktop/src/components/model-picker.tsx +++ b/apps/desktop/src/components/model-picker.tsx @@ -268,9 +268,7 @@ function ModelPrice({ price, isCurrent }: { price?: ModelPricing; isCurrent: boo ) } - const onSale = - typeof price.discount_percent === 'number' && - Boolean(price.was_input || price.was_output) + const onSale = typeof price.discount_percent === 'number' && Boolean(price.was_input || price.was_output) return ( -{price.discount_percent}% diff --git a/apps/desktop/src/lib/desktop-slash-commands.ts b/apps/desktop/src/lib/desktop-slash-commands.ts index 3b386e401b31..22ae4e590e42 100644 --- a/apps/desktop/src/lib/desktop-slash-commands.ts +++ b/apps/desktop/src/lib/desktop-slash-commands.ts @@ -190,7 +190,12 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [ // them, /steer falls back to a next-turn prompt, and /usage is a formatted // live report. Keep them on slash.exec until their RPC contracts are fully // equivalent. - { name: '/agents', description: 'Show active desktop sessions and running tasks', aliases: ['/tasks'], surface: exec() }, + { + name: '/agents', + description: 'Show active desktop sessions and running tasks', + aliases: ['/tasks'], + surface: exec() + }, { name: '/background', description: 'Run a prompt in the background', aliases: ['/bg', '/btw'], surface: exec() }, // /compress must be an action (session.compress RPC), not exec: the slash // worker route times out on large sessions (30s WS / 45s pipe) before the From 73c7f68456afed688863ab5441800b9c6cbdf48f Mon Sep 17 00:00:00 2001 From: ethernet Date: Wed, 22 Jul 2026 18:07:12 -0400 Subject: [PATCH 044/238] fix(desktop): avoid duplicate inflight user rows on resume (#69649) Keep a live session projection from adding its user turn when the latest persisted row already represents that same inflight prompt. Add real Electron coverage for fast and cold resume with idle and background-inference sessions. --- apps/desktop/e2e/fixtures.ts | 10 +- apps/desktop/e2e/large-session-resume.spec.ts | 230 ++++++++++++++++++ apps/desktop/e2e/mock-server.ts | 50 +++- .../desktop/e2e/scripts/seed_large_session.py | 52 ++++ .../hooks/use-session-actions/index.ts | 49 ++-- .../hooks/use-session-actions/utils.test.ts | 26 ++ .../hooks/use-session-actions/utils.ts | 9 +- 7 files changed, 392 insertions(+), 34 deletions(-) create mode 100644 apps/desktop/e2e/large-session-resume.spec.ts create mode 100644 apps/desktop/e2e/scripts/seed_large_session.py diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 7d0ea6be25c9..b03321f051c1 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -27,7 +27,7 @@ import * as path from 'node:path' import { _electron, type ElectronApplication, type Page } from '@playwright/test' -import { startMockServer } from './mock-server' +import { startMockServer, type MockServerOptions } from './mock-server' import { installErrorBannerGuard } from './test' const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..') @@ -331,9 +331,13 @@ export interface MockBackendFixture { * 3. Launch the desktop app * 4. Return handles for test interaction */ -export async function setupMockBackend(): Promise { +export interface MockBackendOptions { + mockServer?: MockServerOptions +} + +export async function setupMockBackend(options: MockBackendOptions = {}): Promise { // 1. Start mock server - const mock = await startMockServer() + const mock = await startMockServer(options.mockServer) // 2. Create sandbox + write config const sandbox = createSandbox('mock') diff --git a/apps/desktop/e2e/large-session-resume.spec.ts b/apps/desktop/e2e/large-session-resume.spec.ts new file mode 100644 index 000000000000..7ea72795a8ae --- /dev/null +++ b/apps/desktop/e2e/large-session-resume.spec.ts @@ -0,0 +1,230 @@ +import { spawnSync } from 'node:child_process' +import * as path from 'node:path' + +import { type TestInfo } from '@playwright/test' + +import { expect, test, type ElectronApplication, type Page } from './test' + +import { + buildAppEnv, + createSandbox, + launchDesktop, + type Sandbox, + waitForAppReady, + writeEnvFile, + writeMockProviderConfig, +} from './fixtures' +import { MOCK_REPLY, startMockServer, type MockServer, type MockServerOptions } from './mock-server' + +const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..') +const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..') +const SEED_SCRIPT = path.resolve(import.meta.dirname, 'scripts', 'seed_large_session.py') +const SESSION_TITLE = 'E2E large persisted session' +const EXPECTED_TEXT = 'E2E persisted user message 52' +const BACKGROUND_PROMPT = 'E2E background inference must remain attached across resume' + +interface SeededFixture { + app: ElectronApplication + mock: MockServer + mockUrl: string + page: Page + sandbox: Sandbox + cleanup: () => Promise +} + +interface PaintState { + bursts: number + timeline: Array<{ mutations: number; time: number }> +} + +async function setupSeededDesktop(mockServer?: MockServerOptions): Promise { + const mock = await startMockServer(mockServer) + const sandbox = createSandbox('large-session') + writeMockProviderConfig(sandbox.hermesHome, mock.url) + writeEnvFile(sandbox.hermesHome) + + const seeded = spawnSync('python3', [SEED_SCRIPT, path.join(sandbox.hermesHome, 'state.db')], { + cwd: REPO_ROOT, + encoding: 'utf8', + env: { ...process.env, PYTHONPATH: REPO_ROOT }, + }) + if (seeded.status !== 0) { + throw new Error(`large-session seed failed:\n${seeded.stdout}\n${seeded.stderr}`) + } + + const { app, page } = await launchDesktop(buildAppEnv(sandbox)) + + return { + app, + mock, + mockUrl: mock.url, + page, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + await mock.close() + sandbox.cleanup() + }, + } +} + +function sessionRow(page: Page) { + return page.locator('[data-slot="sidebar"] button').filter({ hasText: SESSION_TITLE }).first() +} + +async function openSeededSession(page: Page): Promise { + const row = sessionRow(page) + await row.waitFor({ state: 'visible', timeout: 60_000 }) + await row.click() + await page.waitForFunction( + expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), + EXPECTED_TEXT, + { timeout: 30_000 }, + ) +} + +async function openNewSession(page: Page): Promise { + const button = page.locator('[data-slot="sidebar"] button').filter({ hasText: 'New session' }).first() + await button.waitFor({ state: 'visible', timeout: 10_000 }) + await button.click() + await page.waitForFunction( + expected => !(document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), + EXPECTED_TEXT, + { timeout: 15_000 }, + ) +} + +async function submitPrompt(page: Page, prompt: string): Promise { + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 15_000 }) + await composer.click() + await composer.type(prompt, { delay: 2 }) + await page.keyboard.press('Enter') + await page.waitForFunction( + expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), + prompt, + { timeout: 15_000 }, + ) +} + +async function startPaintObserver(page: Page): Promise { + await page.evaluate(() => { + const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + const state = { bursts: 0, timeline: [] as Array<{ mutations: number; time: number }> } + ;(window as Window & { __largeSessionPaints?: typeof state }).__largeSessionPaints = state + if (!viewport) return + + let additions = 0 + let flushTimer: ReturnType | undefined + new MutationObserver(records => { + additions += records.reduce( + (count, record) => count + (record.type === 'childList' && record.addedNodes.length > 0 ? 1 : 0), + 0, + ) + if (additions === 0) return + if (flushTimer) clearTimeout(flushTimer) + flushTimer = setTimeout(() => { + state.bursts += 1 + state.timeline.push({ mutations: additions, time: Date.now() }) + additions = 0 + }, 30) + }).observe(viewport, { childList: true, subtree: true }) + }) +} + +async function paintState(page: Page): Promise { + const state = await page.evaluate(() => (window as Window & { __largeSessionPaints?: PaintState }).__largeSessionPaints) + expect(state, 'paint observer should attach to the thread viewport').toBeDefined() + return state! +} + +async function textNodeOccurrences(page: Page, expected: string): Promise { + return page.evaluate(text => { + const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + if (!viewport) return 0 + + const walker = document.createTreeWalker(viewport, NodeFilter.SHOW_TEXT) + let count = 0 + while (walker.nextNode()) { + if (walker.currentNode.textContent?.includes(text)) { + count += 1 + } + } + return count + }, expected) +} + +async function reloadIntoColdRenderer(fixture: SeededFixture): Promise { + await fixture.page.reload() + await waitForAppReady(fixture, 120_000) + await openNewSession(fixture.page) +} + +async function assertUnchangedResume(page: Page, testInfo: TestInfo): Promise { + await openSeededSession(page) + await page.waitForTimeout(1_000) + await page.screenshot({ path: testInfo.outputPath('unchanged-session-resume.png'), fullPage: false }) + + const paints = await paintState(page) + expect(await textNodeOccurrences(page, EXPECTED_TEXT), 'the resumed user message should appear once').toBe(1) + // A warm session first restores its retained view, then reconciles it with the + // authoritative transcript. That is bounded at two builds; a third paint was + // the old eager-prefetch + runtime-rebuild regression. A cold restore has one. + expect(paints.bursts, `unexpected transcript paint count: ${JSON.stringify(paints.timeline)}`).toBeLessThanOrEqual(2) +} + +test.describe('large session resume', () => { + let fixture: SeededFixture | null = null + + test.afterEach(async () => { + await fixture?.cleanup() + fixture = null + }) + + test('cold resume of an unchanged session has one user row and bounded transcript paints', async ({}, testInfo) => { + fixture = await setupSeededDesktop() + await waitForAppReady(fixture, 120_000) + + await startPaintObserver(fixture.page) + await assertUnchangedResume(fixture.page, testInfo) + }) + + test('fast resume of an unchanged session has one user row and bounded transcript paints', async ({}, testInfo) => { + fixture = await setupSeededDesktop() + await waitForAppReady(fixture, 120_000) + + await openSeededSession(fixture.page) + await openNewSession(fixture.page) + await startPaintObserver(fixture.page) + await assertUnchangedResume(fixture.page, testInfo) + }) + + for (const resumeKind of ['fast', 'cold'] as const) { + test(`${resumeKind} resume keeps background inference attached without duplicate messages`, async ({}, testInfo) => { + fixture = await setupSeededDesktop({ holdFirstStreamForPrompt: BACKGROUND_PROMPT }) + await waitForAppReady(fixture, 120_000) + + await openSeededSession(fixture.page) + await submitPrompt(fixture.page, BACKGROUND_PROMPT) + await fixture.mock.waitForHeldStream() + await openNewSession(fixture.page) + + if (resumeKind === 'cold') { + await reloadIntoColdRenderer(fixture) + } + + await openSeededSession(fixture.page) + fixture.mock.releaseHeldStream() + await fixture.page.waitForFunction( + expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), + MOCK_REPLY, + { timeout: 60_000 }, + ) + await fixture.page.waitForTimeout(300) + await fixture.page.screenshot({ path: testInfo.outputPath(`${resumeKind}-background-inference-resume.png`), fullPage: false }) + + expect(await textNodeOccurrences(fixture.page, BACKGROUND_PROMPT), 'the running user prompt should appear once').toBe(1) + expect(await textNodeOccurrences(fixture.page, MOCK_REPLY), 'the completed assistant reply should appear once').toBe(1) + }) + } +}) diff --git a/apps/desktop/e2e/mock-server.ts b/apps/desktop/e2e/mock-server.ts index f7a4a74b338b..8ff1c0d9548d 100644 --- a/apps/desktop/e2e/mock-server.ts +++ b/apps/desktop/e2e/mock-server.ts @@ -17,21 +17,42 @@ import http from 'node:http' /** A canned assistant reply used for every chat completion request. */ -const CANNED_REPLY = 'Hello from the mock inference server! The full boot chain is working.' +export const MOCK_REPLY = 'Hello from the mock inference server! The full boot chain is working.' + +export interface MockServerOptions { + /** + * Pause a streaming response just after its first token when the latest user + * prompt contains this text. E2E tests use this to switch away from a real, + * still-running inference turn before resuming that session. + */ + holdFirstStreamForPrompt?: string +} + +export interface MockServer { + port: number + url: string + receivedPrompts: string[] + waitForHeldStream: () => Promise + releaseHeldStream: () => void + close: () => Promise +} /** * Start the mock server on an ephemeral port. * * @returns a handle with `port`, `url`, received user prompts, and `close()`. */ -export function startMockServer(): Promise<{ - port: number - url: string - receivedPrompts: string[] - close: () => Promise -}> { +export function startMockServer(options: MockServerOptions = {}): Promise { return new Promise((resolve, reject) => { const receivedPrompts: string[] = [] + let resolveHeldStreamStarted: (() => void) | null = null + let releaseHeldStream: (() => void) | null = null + const heldStreamStarted = new Promise(resolveHeld => { + resolveHeldStreamStarted = resolveHeld + }) + const heldStreamReleased = new Promise(resolveRelease => { + releaseHeldStream = resolveRelease + }) const server = http.createServer((req, res) => { // CORS headers — the Electron renderer doesn't need them, but they // don't hurt and make the server usable from a browser context too. @@ -100,7 +121,11 @@ export function startMockServer(): Promise<{ }) // Send the content in a few chunks to simulate streaming. - const words = CANNED_REPLY.split(' ') + const words = MOCK_REPLY.split(' ') + const holdThisStream = Boolean( + options.holdFirstStreamForPrompt && typeof lastUserMessage?.content === 'string' && + lastUserMessage.content.includes(options.holdFirstStreamForPrompt), + ) let i = 0 const sendChunk = () => { @@ -143,6 +168,11 @@ export function startMockServer(): Promise<{ })}\n\n`, ) i++ + if (holdThisStream && i === 1) { + resolveHeldStreamStarted?.() + heldStreamReleased.then(() => setTimeout(sendChunk, 20)) + return + } // Small delay between chunks to simulate real streaming. setTimeout(sendChunk, 20) } @@ -160,7 +190,7 @@ export function startMockServer(): Promise<{ choices: [ { index: 0, - message: { role: 'assistant', content: CANNED_REPLY }, + message: { role: 'assistant', content: MOCK_REPLY }, finish_reason: 'stop', }, ], @@ -202,6 +232,8 @@ export function startMockServer(): Promise<{ port, url, receivedPrompts, + waitForHeldStream: () => heldStreamStarted, + releaseHeldStream: () => releaseHeldStream?.(), close: () => new Promise((resolveClose, rejectClose) => { server.close((err) => { diff --git a/apps/desktop/e2e/scripts/seed_large_session.py b/apps/desktop/e2e/scripts/seed_large_session.py new file mode 100644 index 000000000000..dc0ef750a691 --- /dev/null +++ b/apps/desktop/e2e/scripts/seed_large_session.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Seed a deterministic, tool-free large session into an isolated state.db.""" + +import sys +from pathlib import Path + +repo_root = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(repo_root)) + +from hermes_state import SessionDB # noqa: E402 + +SESSION_ID = "e2e-large-session" +SESSION_TITLE = "E2E large persisted session" + + +def main() -> None: + if len(sys.argv) != 2: + raise SystemExit(f"usage: {sys.argv[0]} ") + + messages = [] + for index in range(53): + role = "user" if index % 2 == 0 else "assistant" + content = ( + f"E2E persisted user message {index}: audit the compatibility matrix" + if role == "user" + else f"E2E persisted assistant reply {index}: recorded the audit result" + ) + messages.append({"role": role, "content": content, "timestamp": 1_700_000_000 + index}) + + database = SessionDB(db_path=Path(sys.argv[1])) + result = database.import_sessions( + [ + { + "id": SESSION_ID, + "source": "desktop", + "model": "mock-model", + "started_at": 1_700_000_000, + "title": SESSION_TITLE, + "cwd": str(repo_root), + "system_prompt": "", + "messages": messages, + } + ] + ) + database.close() + + if not result.get("ok") or result.get("imported") != 1: + raise SystemExit(f"failed to seed large session: {result}") + + +if __name__ == "__main__": + main() diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 3e6736244040..34cfe1f95559 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -60,7 +60,7 @@ import { } from '@/store/session-states' import { broadcastSessionsChanged } from '@/store/session-sync' import { isWatchWindow } from '@/store/windows' -import type { SessionCreateResponse, SessionResumeResponse, UsageStats } from '@/types/hermes' +import type { SessionCreateResponse, SessionMessage, SessionResumeResponse, UsageStats } from '@/types/hermes' import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../../routes' import type { ClientSessionState, SidebarNavItem } from '../../../types' @@ -778,8 +778,9 @@ export function useSessionActions({ setMessages([]) } + // A history load is not a live turn. Toggling busy here and again in the + // finally block re-renders the thread viewport after it has loaded. busyRef.current = true - setBusy(true) setAwaitingResponse(false) clearNotifications() setSelectedStoredSessionId(storedSessionId) @@ -805,7 +806,6 @@ export function useSessionActions({ : $messages.get() let prefetchApplied = false - let prefetchedMessageCount = 0 let prefetchedStoredSessionId: string | null = null // REST transcript prefetch and the gateway resume RPC are independent @@ -832,24 +832,14 @@ export function useSessionActions({ // keeps it from surfacing as unhandled while the prefetch settles. resumePromise.catch(() => undefined) + // Keep both requests concurrent, but do not paint the REST result until + // the runtime resume has also settled. An eager prefetch paint followed + // by the runtime projection rebuilds large transcripts during resume. + let prefetchedResult: { messages: SessionMessage[]; session_id?: string } | null = null + try { if (prefetchPromise) { - const storedMessages = await prefetchPromise - - if (isCurrentResume()) { - const previousMessages = resumedSameSelectedSession - ? preserveLocalPendingTurnMessages($messages.get(), resumeStartMessages) - : $messages.get() - - localSnapshot = reconcileAuthoritativeMessages(storedMessages.messages, previousMessages) - prefetchApplied = true - prefetchedMessageCount = storedMessages.messages.length - prefetchedStoredSessionId = storedMessages.session_id || storedSessionId - - if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) { - setMessages(localSnapshot) - } - } + prefetchedResult = await prefetchPromise } } catch { // Non-fatal: gateway resume below can still hydrate the session. @@ -861,6 +851,16 @@ export function useSessionActions({ return } + if (prefetchedResult) { + const previousMessages = resumedSameSelectedSession + ? preserveLocalPendingTurnMessages($messages.get(), resumeStartMessages) + : $messages.get() + + localSnapshot = reconcileAuthoritativeMessages(prefetchedResult.messages, previousMessages) + prefetchApplied = true + prefetchedStoredSessionId = prefetchedResult.session_id || storedSessionId + } + const currentMessages = $messages.get() // Keep the local snapshot when resume would only reshuffle runtime @@ -878,8 +878,7 @@ export function useSessionActions({ const preferredMessages = prefetchApplied && prefetchMatchesResumedSession && - !hasLiveProjection && - resumed.messages.length <= prefetchedMessageCount + !hasLiveProjection ? localSnapshot : (() => { const previousMessages = resumedSameSelectedSession @@ -927,6 +926,14 @@ export function useSessionActions({ }), storedSessionId ) + + // updateSessionState stages its view sync through requestAnimationFrame. + // Commit the final, already-reconciled transcript now so resume has one + // additive DOM build instead of an eager prefetch build plus a later + // runtime projection build. + if (!chatMessageArraysEquivalent($messages.get(), messagesForView)) { + setMessages(messagesForView) + } } catch (err) { if (!isCurrentResume()) { return diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts index 84e0d232fe2b..652cd0af3717 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts @@ -354,6 +354,32 @@ describe('appendLiveSessionProjection', () => { expect(restored[3]).toMatchObject({ id: 'assistant-stream-runtime-1', pending: true }) }) + it('does not duplicate a persisted inflight user after consecutive canceled user turns', () => { + const stored = [ + msg('stored-user-1', 'user', 'canceled prompt one'), + msg('stored-user-2', 'user', 'canceled prompt two'), + msg('stored-user-3', 'user', 'current running prompt') + ] + + const restored = appendLiveSessionProjection(stored, { + session_id: 'runtime-1', + inflight: { + user: 'current running prompt', + assistant: 'partial answer', + streaming: true + } + }) + + expect(restored.map(message => message.role)).toEqual(['user', 'user', 'user', 'assistant']) + expect(restored.map(message => message.parts.map(part => ('text' in part ? part.text : '')).join(''))).toEqual([ + 'canceled prompt one', + 'canceled prompt two', + 'current running prompt', + 'partial answer' + ]) + expect(restored[3]).toMatchObject({ id: 'assistant-stream-runtime-1', pending: true }) + }) + it('preserves the original array when no live projection exists', () => { const stored = [msg('stored-user', 'user', 'earlier')] diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index feb22b9e658a..2604a68841df 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -314,8 +314,15 @@ export function appendLiveSessionProjection( const sessionId = projection.session_id || 'session' const projected: ChatMessage[] = [] + // A turn normally persists its user row before inference begins. session.resume + // then returns that stored row *and* the still-live inflight projection; adding + // both makes a backgrounded prompt appear twice when its session is reopened. + // Only suppress the projection when the latest authoritative user row is the + // same turn — older identical prompts must not hide a newly accepted repeat. + const latestUser = [...messages].reverse().find(message => message.role === 'user') + const inflightUserAlreadyPersisted = latestUser && chatMessageText(latestUser).trim() === inflightUser - if (inflightUser) { + if (inflightUser && !inflightUserAlreadyPersisted) { projected.push({ id: `user-inflight-${sessionId}`, role: 'user', From 15dc65eeda397a5d4d35edd6779141eeb8139944 Mon Sep 17 00:00:00 2001 From: "hermes-seaeye[bot]" <307254004+hermes-seaeye[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:14:06 +0000 Subject: [PATCH 045/238] fmt(js): `npm run fix` on merge (#69651) Co-authored-by: github-actions[bot] --- .../src/app/session/hooks/use-session-actions/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 34cfe1f95559..9adb6f7e41a0 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -876,9 +876,7 @@ export function useSessionActions({ const hasLiveProjection = Boolean(resumed.inflight || resumed.queued) const preferredMessages = - prefetchApplied && - prefetchMatchesResumedSession && - !hasLiveProjection + prefetchApplied && prefetchMatchesResumedSession && !hasLiveProjection ? localSnapshot : (() => { const previousMessages = resumedSameSelectedSession From 592effcb2a42dbdf0b51ac9fef7176066ba86ac6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 17:46:55 -0500 Subject: [PATCH 046/238] feat(tts): provider-agnostic streaming core with a shared sentence chunker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StreamingTTSProvider ABC + registry + resolve_streaming_provider() — ElevenLabs (pcm_24000) and OpenAI (response_format=pcm) stream chunked PCM; every other provider keeps its configured voice and falls back to per-sentence sync synthesis. SentenceChunker is the one incremental cutter every surface shares: sentence-boundary cuts on the delta stream, blocks stripped even when split across deltas, short fragments merged forward so they never stall as tiny clips. --- tests/tools/test_tts_streaming.py | 228 ++++++++++++++++++++++++++++++ tools/tts_streaming.py | 193 +++++++++++++++++++++++++ 2 files changed, 421 insertions(+) create mode 100644 tests/tools/test_tts_streaming.py create mode 100644 tools/tts_streaming.py diff --git a/tests/tools/test_tts_streaming.py b/tests/tools/test_tts_streaming.py new file mode 100644 index 000000000000..77241ea485b7 --- /dev/null +++ b/tests/tools/test_tts_streaming.py @@ -0,0 +1,228 @@ +"""Tests for the provider-agnostic streaming TTS backend (tools.tts_streaming) +and its dispatch through tools.tts_tool.stream_tts_to_speaker. + +No live audio or network: the ElevenLabs/OpenAI SDKs, sounddevice, and the sync +synth path are all mocked. Covers the registry/resolver, provider availability, +the chunked-streamer playback path, and the universal per-sentence sync fallback. +""" + +import queue +import threading +from unittest.mock import MagicMock, patch + +import pytest + +import tools.tts_streaming as ts + +pytest.importorskip("numpy") + + +# ── SentenceChunker ────────────────────────────────────────────────────── + + +class TestSentenceChunker: + def test_cuts_sentence_the_moment_its_boundary_arrives(self): + c = ts.SentenceChunker() + assert c.feed("This is the first full") == [] + assert c.feed(" sentence of it all. And") == ["This is the first full sentence of it all. "] + assert c.flush() == ["And"] + + def test_short_fragment_rides_with_the_next_sentence(self): + c = ts.SentenceChunker() + # "Ha! " alone is under min_len — it must not become its own clip. + assert c.feed("Ha! ") == [] + assert c.feed("That was a good one, honestly. ") == [ + "Ha! That was a good one, honestly. " + ] + + def test_think_blocks_are_stripped_even_across_deltas(self): + c = ts.SentenceChunker() + assert c.feed("secret reason") == [] + assert c.feed("ingThe actual spoken answer. ") == ["The actual spoken answer. "] + + def test_flush_drains_the_tail(self): + c = ts.SentenceChunker() + c.feed("no boundary here") + assert c.flush() == ["no boundary here"] + assert c.flush() == [] + + def test_paragraph_break_is_a_boundary(self): + c = ts.SentenceChunker() + assert c.feed("A paragraph without punctuation\n\nnext one") == [ + "A paragraph without punctuation\n\n" + ] + + +# ── Registry + resolver ────────────────────────────────────────────────── + + +def _register_fake(monkeypatch, name, available=True, chunks=(b"\x00\x00",)): + class _Fake(ts.StreamingTTSProvider): + sample_rate = 24000 + + @staticmethod + def available(): + return available + + def stream(self, text): + yield from chunks + + monkeypatch.setitem(ts._REGISTRY, name, _Fake) + return _Fake + + +def test_resolve_returns_configured_streamer(monkeypatch): + _register_fake(monkeypatch, "faketts") + prov = ts.resolve_streaming_provider({"provider": "faketts"}) + assert isinstance(prov, ts.StreamingTTSProvider) + + +def test_resolve_none_for_unregistered_provider(monkeypatch): + # edge is a sync provider — not registered — so the dispatcher keeps its voice. + assert ts.resolve_streaming_provider({"provider": "edge"}) is None + + +def test_resolve_none_when_provider_unavailable(monkeypatch): + _register_fake(monkeypatch, "faketts", available=False) + assert ts.resolve_streaming_provider({"provider": "faketts"}) is None + + +def test_resolve_honors_preferred_override(monkeypatch): + _register_fake(monkeypatch, "faketts") + prov = ts.resolve_streaming_provider({"provider": "edge"}, preferred="faketts") + assert isinstance(prov, ts.StreamingTTSProvider) + + +def test_never_swaps_provider_for_streaming(monkeypatch): + # A registered streamer must NOT be substituted when the user picked another + # (non-streaming) provider — that would silently change their voice. + _register_fake(monkeypatch, "elevenlabs") + assert ts.resolve_streaming_provider({"provider": "edge"}) is None + + +# ── Built-in provider availability ─────────────────────────────────────── + + +def test_elevenlabs_available_reflects_key(monkeypatch): + monkeypatch.setattr(ts, "get_env_value", lambda k, *a: "key" if k == "ELEVENLABS_API_KEY" else None) + assert ts.ElevenLabsStreamer.available() is True + monkeypatch.setattr(ts, "get_env_value", lambda k, *a: None) + assert ts.ElevenLabsStreamer.available() is False + + +def test_openai_available_reflects_key(monkeypatch): + monkeypatch.setattr(ts, "get_env_value", lambda k, *a: "key" if k == "OPENAI_API_KEY" else None) + assert ts.OpenAIStreamer.available() is True + + +# ── Dispatch: chunked streamer path ────────────────────────────────────── + + +def _drain_queue(sentences): + q = queue.Queue() + for s in sentences: + q.put(s) + q.put(None) + return q + + +def _sd_mock(): + sd = MagicMock() + out = MagicMock() + sd.OutputStream.return_value = out + return sd, out + + +def test_streamer_path_writes_pcm_to_output(monkeypatch): + from tools import tts_tool + + class _Fake(ts.StreamingTTSProvider): + sample_rate = 24000 + + @staticmethod + def available(): + return True + + def stream(self, text): + yield b"\x01\x00" * 50 + yield b"\x02\x00" * 50 + + sd, out = _sd_mock() + q = _drain_queue(["Hello there, this is a full sentence."]) + stop, done = threading.Event(), threading.Event() + + with patch("tools.tts_streaming.resolve_streaming_provider", return_value=_Fake({}, {})), \ + patch.object(tts_tool, "_import_sounddevice", return_value=sd): + tts_tool.stream_tts_to_speaker(q, stop, done) + + assert out.write.called, "expected PCM chunks written to the output stream" + assert done.is_set() + + +def test_stop_event_aborts_streaming(monkeypatch): + from tools import tts_tool + + class _Fake(ts.StreamingTTSProvider): + sample_rate = 24000 + + @staticmethod + def available(): + return True + + def stream(self, text): + for _ in range(1000): + yield b"\x00\x00" * 50 + + sd, out = _sd_mock() + stop, done = threading.Event(), threading.Event() + stop.set() # pre-set: no audio should be written + q = _drain_queue(["A complete sentence here."]) + + with patch("tools.tts_streaming.resolve_streaming_provider", return_value=_Fake({}, {})), \ + patch.object(tts_tool, "_import_sounddevice", return_value=sd): + tts_tool.stream_tts_to_speaker(q, stop, done) + + assert not out.write.called + assert done.is_set() + + +# ── Dispatch: universal per-sentence sync fallback ─────────────────────── + + +def test_sync_fallback_speaks_each_sentence(monkeypatch): + from tools import tts_tool + + spoken = [] + monkeypatch.setattr(tts_tool, "text_to_speech_tool", + lambda text, output_path: spoken.append(text)) + played = [] + fake_vm = MagicMock() + fake_vm.play_audio_file.side_effect = lambda p: played.append(p) + monkeypatch.setitem(__import__("sys").modules, "tools.voice_mode", fake_vm) + monkeypatch.setattr("os.path.getsize", lambda p: 100) + monkeypatch.setattr("os.path.isfile", lambda p: True) + + q = _drain_queue(["First full sentence here. ", "Second full sentence here. "]) + stop, done = threading.Event(), threading.Event() + + with patch("tools.tts_streaming.resolve_streaming_provider", return_value=None): + tts_tool.stream_tts_to_speaker(q, stop, done) + + assert len(spoken) == 2, f"expected both sentences synthesized, got {spoken}" + assert len(played) == 2 + assert done.is_set() + + +def test_display_callback_fires_without_audio(monkeypatch): + from tools import tts_tool + + seen = [] + monkeypatch.setattr(tts_tool, "text_to_speech_tool", lambda text, output_path: None) + q = _drain_queue(["A sentence to display aloud."]) + stop, done = threading.Event(), threading.Event() + + with patch("tools.tts_streaming.resolve_streaming_provider", return_value=None): + tts_tool.stream_tts_to_speaker(q, stop, done, display_callback=seen.append) + + assert seen, "display_callback should fire even on the sync path" + assert done.is_set() diff --git a/tools/tts_streaming.py b/tools/tts_streaming.py new file mode 100644 index 000000000000..8d773d954f59 --- /dev/null +++ b/tools/tts_streaming.py @@ -0,0 +1,193 @@ +"""Provider-agnostic streaming TTS: sentence text → int16 PCM chunk iterator. + +The keystone of Hermes' conversational voice UX. `stream_tts_to_speaker` +(``tools.tts_tool``) owns the sentence buffer, sounddevice output, and +stop/queue protocol; this module owns the *provider* half — turning one +sentence into audio the moment it's ready, so playback starts on sentence one +instead of after the whole reply. + +Two provider shapes, one contract (int16 mono PCM at ``sample_rate``): + +* **True streamers** (`StreamingTTSProvider.stream`) — chunked APIs + (ElevenLabs pcm_24000, OpenAI pcm, …) that yield audio as it synthesizes. + Lowest time-to-first-audio. +* **Everyone else** — providers with no chunked API still get per-*sentence* + playback via the proven sync `text_to_speech_tool` path (handled by the + dispatcher, not here), so edge (the default) is conversational too. + +Adding a streamer is `@register("name")` on a `StreamingTTSProvider` subclass; +the dispatcher, config gate (`tts..streaming`), and resolver come free. +""" + +from __future__ import annotations + +import logging +import re +from abc import ABC, abstractmethod +from typing import Callable, Dict, Iterator, List, Optional + +from tools.tts_tool import _get_provider, get_env_value + +logger = logging.getLogger(__name__) + +# Sentence boundary: after .!? followed by whitespace, or a blank line. +SENTENCE_BOUNDARY_RE = re.compile(r"(?<=[.!?])(?:\s|\n)|(?:\n\n)") +_THINK_BLOCK_RE = re.compile(r"].*?", flags=re.DOTALL) + + +class SentenceChunker: + """Incremental sentence cutter for LLM token deltas. + + Shared by the speaker pipeline (`stream_tts_to_speaker`) and the + speak-stream WebSocket so every surface cuts speech identically. Strips + ```` blocks (even split across deltas) and merges fragments shorter + than *min_len* into the following sentence, so "Ha!" rides along with the + sentence after it instead of stalling as a tiny clip. + """ + + def __init__(self, min_len: int = 20): + self.min_len = min_len + self.buf = "" + + def feed(self, delta: str) -> List[str]: + """Absorb *delta*; return every complete sentence now ready to speak.""" + self.buf = _THINK_BLOCK_RE.sub("", self.buf + delta) + if "" not in self.buf: + return [] # open think tag — the closing tag may arrive next delta + out: List[str] = [] + start = 0 # skip boundaries that would leave the head too short + while m := SENTENCE_BOUNDARY_RE.search(self.buf, start): + head = self.buf[: m.end()] + if len(head.strip()) < self.min_len: + start = m.end() + continue + out.append(head) + self.buf = self.buf[m.end():] + start = 0 + return out + + def flush(self) -> List[str]: + """Drain the tail (end-of-text or long-idle flush).""" + tail = _THINK_BLOCK_RE.sub("", self.buf).strip() + self.buf = "" + return [tail] if tail else [] + + +# --------------------------------------------------------------------------- +# ABC + registry +# --------------------------------------------------------------------------- + +class StreamingTTSProvider(ABC): + """Yields raw int16, little-endian, mono PCM chunks at ``sample_rate``.""" + + sample_rate: int = 24000 + channels: int = 1 + sample_width: int = 2 # bytes/sample (int16) + + def __init__(self, tts_config: Dict, section: Dict): + self.tts_config = tts_config + self.section = section + + @staticmethod + @abstractmethod + def available() -> bool: + """True when this provider's credentials/SDK are usable right now.""" + + @abstractmethod + def stream(self, text: str) -> Iterator[bytes]: + """Yield PCM chunks for ``text``. Raise on failure (caller logs).""" + + +_REGISTRY: Dict[str, type[StreamingTTSProvider]] = {} + + +def register(name: str) -> Callable[[type[StreamingTTSProvider]], type[StreamingTTSProvider]]: + def _wrap(cls: type[StreamingTTSProvider]) -> type[StreamingTTSProvider]: + _REGISTRY[name] = cls + return cls + + return _wrap + + +def resolve_streaming_provider( + tts_config: Dict, + preferred: Optional[str] = None, +) -> Optional[StreamingTTSProvider]: + """Return a ready streamer for the *configured* provider, else ``None``. + + ``None`` means "no chunked API for this provider" — the dispatcher then + speaks per-sentence via the sync path, preserving the user's chosen voice. + We never silently swap to a different provider just to get streaming. + """ + name = (preferred or _get_provider(tts_config)).lower().strip() + cls = _REGISTRY.get(name) + if cls is None or not cls.available(): + return None + try: + return cls(tts_config, tts_config.get(name) or {}) + except Exception as exc: # pragma: no cover - defensive + logger.debug("streaming provider %s init failed: %s", name, exc) + return None + + +# --------------------------------------------------------------------------- +# Providers +# --------------------------------------------------------------------------- + +@register("elevenlabs") +class ElevenLabsStreamer(StreamingTTSProvider): + """ElevenLabs chunked HTTP → pcm_24000 (the original reference path).""" + + sample_rate = 24000 + + @staticmethod + def available() -> bool: + return bool(get_env_value("ELEVENLABS_API_KEY")) + + def stream(self, text: str) -> Iterator[bytes]: + from tools.tts_tool import ( + DEFAULT_ELEVENLABS_STREAMING_MODEL_ID, + DEFAULT_ELEVENLABS_VOICE_ID, + _import_elevenlabs, + ) + + client = _import_elevenlabs()(api_key=get_env_value("ELEVENLABS_API_KEY")) + voice_id = self.section.get("voice_id", DEFAULT_ELEVENLABS_VOICE_ID) + model_id = self.section.get( + "streaming_model_id", + self.section.get("model_id", DEFAULT_ELEVENLABS_STREAMING_MODEL_ID), + ) + yield from client.text_to_speech.convert( + text=text, + voice_id=voice_id, + model_id=model_id, + output_format="pcm_24000", + ) + + +@register("openai") +class OpenAIStreamer(StreamingTTSProvider): + """OpenAI speech with ``response_format=pcm`` (24 kHz mono int16).""" + + sample_rate = 24000 + + @staticmethod + def available() -> bool: + return bool(get_env_value("OPENAI_API_KEY")) + + def stream(self, text: str) -> Iterator[bytes]: + from openai import OpenAI + + client = OpenAI( + api_key=get_env_value("OPENAI_API_KEY"), + base_url=get_env_value("OPENAI_BASE_URL") or None, + ) + model = self.section.get("model", "gpt-4o-mini-tts") + voice = self.section.get("voice", "alloy") + with client.audio.speech.with_streaming_response.create( + model=model, + voice=voice, + input=text, + response_format="pcm", + ) as response: + yield from response.iter_bytes() From 8da98ce082fa2dd0dcc0a09cf0755eb35ef72324 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 17:46:55 -0500 Subject: [PATCH 047/238] feat(tts): route the speaker pipeline through the streaming core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stream_tts_to_speaker() drops its hardcoded ElevenLabs client for resolve_streaming_provider() + SentenceChunker, so any provider speaks sentence-by-sentence while the model is still generating. Markdown stripping also drops emoji — providers stall on them or read them out loud. --- tools/tts_tool.py | 192 +++++++++++++++++++++------------------------- 1 file changed, 88 insertions(+), 104 deletions(-) diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 545d72bb6907..cbc9b3474931 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -2716,11 +2716,8 @@ def _has_openai_audio_backend() -> bool: # =========================================================================== -# Streaming TTS: sentence-by-sentence pipeline for ElevenLabs +# Streaming TTS: sentence-by-sentence pipeline # =========================================================================== -# Sentence boundary pattern: punctuation followed by space or newline -_SENTENCE_BOUNDARY_RE = re.compile(r'(?<=[.!?])(?:\s|\n)|(?:\n\n)') - # Markdown stripping patterns (same as cli.py _voice_speak_response) _MD_CODE_BLOCK = re.compile(r'```[\s\S]*?```') _MD_LINK = re.compile(r'\[([^\]]+)\]\([^)]+\)') @@ -2732,10 +2729,15 @@ _MD_HEADER = re.compile(r'^#+\s*', flags=re.MULTILINE) _MD_LIST_ITEM = re.compile(r'^\s*[-*]\s+', flags=re.MULTILINE) _MD_HR = re.compile(r'---+') _MD_EXCESS_NL = re.compile(r'\n{3,}') +# Emoji + variation selectors/ZWJ — TTS providers render these as awkward +# pauses or literal descriptions ("smiling face"), breaking the speech flow. +_EMOJI = re.compile( + '[\U0001F000-\U0001FAFF\u2600-\u27BF\uFE0F\u200D\U000E0020-\U000E007F]+' +) def _strip_markdown_for_tts(text: str) -> str: - """Remove markdown formatting that shouldn't be spoken aloud.""" + """Remove markdown formatting (and emoji) that shouldn't be spoken aloud.""" text = _MD_CODE_BLOCK.sub(' ', text) text = _MD_LINK.sub(r'\1', text) text = _MD_URL.sub('', text) @@ -2745,6 +2747,7 @@ def _strip_markdown_for_tts(text: str) -> str: text = _MD_HEADER.sub('', text) text = _MD_LIST_ITEM.sub('', text) text = _MD_HR.sub('', text) + text = _EMOJI.sub(' ', text) text = _MD_EXCESS_NL.sub('\n\n', text) return text.strip() @@ -2754,75 +2757,62 @@ def stream_tts_to_speaker( stop_event: threading.Event, tts_done_event: threading.Event, display_callback: Optional[Callable[[str], None]] = None, + provider: Optional[str] = None, ): - """Consume text deltas from *text_queue*, buffer them into sentences, - and stream each sentence through ElevenLabs TTS to the speaker in - real-time. + """Consume text deltas from *text_queue*, buffer them into sentences, and + speak each sentence the moment it's ready — the conversational path. + + Provider-agnostic. A registered streaming provider (ElevenLabs, OpenAI, …) + plays chunked PCM through one sounddevice stream for the lowest latency; + every other provider (edge, the default) is spoken per-sentence via the sync + ``text_to_speech_tool`` path, so audio still starts on sentence one instead + of after the whole reply. Protocol: * The producer puts ``str`` deltas onto *text_queue*. * A ``None`` sentinel signals end-of-text (flush remaining buffer). - * *stop_event* can be set to abort early (e.g. user interrupt). + * *stop_event* can be set to abort early (barge-in / user interrupt). * *tts_done_event* is **set** in the ``finally`` block so callers waiting on it (continuous voice mode) know playback is finished. """ tts_done_event.clear() try: - # --- TTS client setup (optional -- display_callback works without it) --- - client = None output_stream = None - voice_id = DEFAULT_ELEVENLABS_VOICE_ID - model_id = DEFAULT_ELEVENLABS_STREAMING_MODEL_ID - tts_config = _load_tts_config() - el_config = tts_config.get("elevenlabs") or {} - voice_id = el_config.get("voice_id", voice_id) - model_id = el_config.get("streaming_model_id", - el_config.get("model_id", model_id)) - # Per-sentence cap for the streaming path. Look up the cap against - # the *streaming* model_id (defaults to eleven_flash_v2_5 = 40k chars), - # not the sync model_id. A user override - # (tts.elevenlabs.max_text_length) still wins. - stream_max_len = _resolve_max_text_length( - "elevenlabs", - {**tts_config, "elevenlabs": {**el_config, "model_id": model_id}}, - ) - api_key = (get_env_value("ELEVENLABS_API_KEY") or "") - if not api_key: - logger.warning("ELEVENLABS_API_KEY not set; streaming TTS audio disabled") - else: + # Prefer a chunked streamer for low time-to-first-audio; fall back to + # per-sentence sync synthesis (universal — edge + every non-streamer). + from tools.tts_streaming import SentenceChunker, resolve_streaming_provider + streamer = resolve_streaming_provider(tts_config, preferred=provider) + + stream_max_len = 0 + if streamer is not None: try: - ElevenLabs = _import_elevenlabs() - client = ElevenLabs(api_key=api_key) - except ImportError: - logger.warning("elevenlabs package not installed; streaming TTS disabled") + stream_max_len = _resolve_max_text_length( + provider or _get_provider(tts_config), tts_config + ) + except Exception: + stream_max_len = 0 + try: + sd = _import_sounddevice() + output_stream = sd.OutputStream( + samplerate=streamer.sample_rate, + channels=streamer.channels, + dtype="int16", + ) + output_stream.start() + except (ImportError, OSError) as exc: + logger.debug("sounddevice not available, streamer→tempfile: %s", exc) + output_stream = None + except Exception as exc: + logger.warning("sounddevice OutputStream failed: %s", exc) + output_stream = None - # Open a single sounddevice output stream for the lifetime of - # this function. ElevenLabs pcm_24000 produces signed 16-bit - # little-endian mono PCM at 24 kHz. - if client is not None: - try: - sd = _import_sounddevice() - output_stream = sd.OutputStream( - samplerate=24000, channels=1, dtype="int16", - ) - output_stream.start() - except (ImportError, OSError) as exc: - logger.debug("sounddevice not available: %s", exc) - output_stream = None - except Exception as exc: - logger.warning("sounddevice OutputStream failed: %s", exc) - output_stream = None - - sentence_buf = "" - min_sentence_len = 20 + chunker = SentenceChunker() long_flush_len = 100 queue_timeout = 0.5 _spoken_sentences: list[str] = [] # track spoken sentences to skip duplicates - # Regex to strip complete ... blocks from buffer - _think_block_re = re.compile(r'].*?', flags=re.DOTALL) def _speak_sentence(sentence: str): """Display sentence and optionally generate + play audio.""" @@ -2840,33 +2830,51 @@ def stream_tts_to_speaker( # Display raw sentence on screen before TTS processing if display_callback is not None: display_callback(sentence) - # Skip audio generation if no TTS client available - if client is None: + # No chunked streamer → per-sentence sync synthesis (universal). + if streamer is None: + _speak_via_sync(cleaned) return - # Truncate very long sentences (ElevenLabs streaming path) - if len(cleaned) > stream_max_len: + # Truncate very long sentences to the provider's per-request cap. + if stream_max_len and len(cleaned) > stream_max_len: cleaned = cleaned[:stream_max_len] try: - audio_iter = client.text_to_speech.convert( - text=cleaned, - voice_id=voice_id, - model_id=model_id, - output_format="pcm_24000", - ) + audio_iter = streamer.stream(cleaned) if output_stream is not None: + import numpy as _np for chunk in audio_iter: if stop_event.is_set(): break - import numpy as _np - audio_array = _np.frombuffer(chunk, dtype=_np.int16) - output_stream.write(audio_array.reshape(-1, 1)) + output_stream.write(_np.frombuffer(chunk, dtype=_np.int16).reshape(-1, 1)) else: - # Fallback: write chunks to temp file and play via system player - _play_via_tempfile(audio_iter, stop_event) + # No audio device: buffer chunks to a temp WAV and play it. + _play_via_tempfile(audio_iter, stop_event, streamer.sample_rate) except Exception as exc: logger.warning("Streaming TTS sentence failed: %s", exc) - def _play_via_tempfile(audio_iter, stop_evt): + def _speak_via_sync(cleaned: str): + """Synthesize one sentence via the proven sync tool, then block on + playback. No chunked API, but per-*sentence* granularity keeps the + flow conversational for edge and every other non-streaming provider. + """ + tmp_path = None + try: + fd, tmp_path = tempfile.mkstemp(suffix=".mp3") + os.close(fd) + text_to_speech_tool(text=cleaned, output_path=tmp_path) + if (not stop_event.is_set() and os.path.isfile(tmp_path) + and os.path.getsize(tmp_path) > 0): + from tools.voice_mode import play_audio_file + play_audio_file(tmp_path) + except Exception as exc: + logger.warning("Sync per-sentence TTS failed: %s", exc) + finally: + if tmp_path: + try: + os.unlink(tmp_path) + except OSError: + pass + + def _play_via_tempfile(audio_iter, stop_evt, sample_rate=24000): """Write PCM chunks to a temp WAV file and play it.""" tmp_path = None try: @@ -2876,7 +2884,7 @@ def stream_tts_to_speaker( with wave.open(tmp, "wb") as wf: wf.setnchannels(1) wf.setsampwidth(2) # 16-bit - wf.setframerate(24000) + wf.setframerate(sample_rate) for chunk in audio_iter: if stop_evt.is_set(): break @@ -2897,43 +2905,19 @@ def stream_tts_to_speaker( try: delta = text_queue.get(timeout=queue_timeout) except queue.Empty: - # Timeout: if we have accumulated a long buffer, flush it - if len(sentence_buf) > long_flush_len: - _speak_sentence(sentence_buf) - sentence_buf = "" + # Idle producer: flush a long buffer instead of sitting on it + if len(chunker.buf) > long_flush_len: + for sentence in chunker.flush(): + _speak_sentence(sentence) continue if delta is None: - # End-of-text sentinel: strip any remaining think blocks, flush - sentence_buf = _think_block_re.sub('', sentence_buf) - if sentence_buf.strip(): - _speak_sentence(sentence_buf) + # End-of-text sentinel: flush whatever remains + for sentence in chunker.flush(): + _speak_sentence(sentence) break - sentence_buf += delta - - # --- Think block filtering --- - # Strip complete ... blocks from buffer. - # Works correctly even when tags span multiple deltas. - sentence_buf = _think_block_re.sub('', sentence_buf) - - # If an incomplete ' not in sentence_buf: - continue - - # Check for sentence boundaries - while True: - m = _SENTENCE_BOUNDARY_RE.search(sentence_buf) - if m is None: - break - end_pos = m.end() - sentence = sentence_buf[:end_pos] - sentence_buf = sentence_buf[end_pos:] - # Merge short fragments into the next sentence - if len(sentence.strip()) < min_sentence_len: - sentence_buf = sentence + sentence_buf - break + for sentence in chunker.feed(delta): _speak_sentence(sentence) # Drain any remaining items from the queue From d8f30b1df7d58a68d722ede84bde60352a655c75 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 17:47:04 -0500 Subject: [PATCH 048/238] fix(stt): flag empty transcripts as no_speech An STT provider that hears no words is reporting silence, not failing. ElevenLabs/xAI empty-transcript errors now carry no_speech so live voice loops can re-listen quietly instead of surfacing an error on every pause. --- tests/tools/test_transcription_tools.py | 2 ++ tools/transcription_tools.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/tests/tools/test_transcription_tools.py b/tests/tools/test_transcription_tools.py index 434971e9aac4..0997872c69fd 100644 --- a/tests/tools/test_transcription_tools.py +++ b/tests/tools/test_transcription_tools.py @@ -1209,6 +1209,7 @@ class TestTranscribeXAI: assert result["success"] is False assert "empty transcript" in result["error"] + assert result["no_speech"] is True # live voice loops treat this as silence def test_permission_error(self, monkeypatch, sample_ogg, mock_xai_http_module): monkeypatch.setenv("XAI_API_KEY", "xai-test-key") @@ -1453,6 +1454,7 @@ class TestTranscribeElevenLabs: assert result["success"] is False assert "empty transcript" in result["error"] + assert result["no_speech"] is True # live voice loops treat this as silence # ============================================================================ diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index 39e92261a19c..fd351c710710 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -1546,6 +1546,7 @@ def _transcribe_xai(file_path: str, model_name: str) -> Dict[str, Any]: "success": False, "transcript": "", "error": "xAI STT returned empty transcript", + "no_speech": True, } logger.info( @@ -1633,6 +1634,7 @@ def _transcribe_elevenlabs(file_path: str, model_name: str) -> Dict[str, Any]: "success": False, "transcript": "", "error": "ElevenLabs STT returned empty transcript", + "no_speech": True, } logger.info( From 8ce18d2557ba23de1d659a3aea116967742d059e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 17:47:05 -0500 Subject: [PATCH 049/238] feat(voice): barge-in detector with pre-roll utterance capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listen_for_speech(): sustained-RMS speech detection with the noise floor calibrated against audible playback (speaker bleed doesn't self-trigger). With capture=True it keeps a rolling pre-roll buffer and records through to a silence endpoint, so the interruption is transcribed from its first syllable — detection alone loses the opening words to the detector's sustain window plus the mic re-open. transcribe_recording() maps no_speech provider results to a successful empty transcript (silence, not an error). --- tests/tools/test_voice_mode.py | 154 +++++++++++++++++++++++++++++++++ tools/voice_mode.py | 96 +++++++++++++++++++- 2 files changed, 249 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_voice_mode.py b/tests/tools/test_voice_mode.py index b092250e2b44..785274786970 100644 --- a/tests/tools/test_voice_mode.py +++ b/tests/tools/test_voice_mode.py @@ -776,6 +776,37 @@ class TestTranscribeRecording: assert result["transcript"] == "" assert result["filtered"] is True + def test_no_speech_failure_maps_to_silent_success(self): + """Provider "empty transcript" errors are silence, not failure — the + voice loop should re-listen quietly instead of showing an error.""" + mock_transcribe = MagicMock(return_value={ + "success": False, + "transcript": "", + "error": "ElevenLabs STT returned empty transcript", + "no_speech": True, + }) + + with patch("tools.transcription_tools.transcribe_audio", mock_transcribe): + from tools.voice_mode import transcribe_recording + result = transcribe_recording("/tmp/test.wav") + + assert result["success"] is True + assert result["transcript"] == "" + assert result["no_speech"] is True + + def test_real_failures_still_fail(self): + mock_transcribe = MagicMock(return_value={ + "success": False, + "transcript": "", + "error": "xAI STT API error (HTTP 500): boom", + }) + + with patch("tools.transcription_tools.transcribe_audio", mock_transcribe): + from tools.voice_mode import transcribe_recording + result = transcribe_recording("/tmp/test.wav") + + assert result["success"] is False + def test_does_not_filter_real_speech(self): mock_transcribe = MagicMock(return_value={ "success": True, @@ -1476,3 +1507,126 @@ class TestSilenceCallbackLock: recorder.cancel() with recorder._lock: assert recorder._on_silence_stop is None + + +# ============================================================================ +# listen_for_speech — VAD barge-in monitor +# ============================================================================ + +class _FakeInputStream: + """Context-manager InputStream serving a fixed sequence of RMS levels.""" + + def __init__(self, np, levels): + self._np = np + self._levels = list(levels) + self.reads = 0 + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self, frames): + level = self._levels[min(self.reads, len(self._levels) - 1)] + self.reads += 1 + return self._np.full((frames, 1), level, dtype=self._np.int16), False + + +class TestListenForSpeech: + """listen_for_speech: calibration → sustained-speech trigger → barge-in.""" + + CALIB_BLOCKS = 14 # 400ms / 30ms + TRIP_BLOCKS = 10 # 300ms / 30ms + + def _run(self, mock_sd, levels, should_stop=None, **kwargs): + np = pytest.importorskip("numpy") + stream = _FakeInputStream(np, levels) + mock_sd.InputStream.return_value = stream + from tools.voice_mode import listen_for_speech + stops = iter([False] * 200 + [True] * 10_000) + return listen_for_speech(should_stop or (lambda: next(stops)), **kwargs), stream + + def test_sustained_speech_triggers(self, mock_sd): + levels = [0] * self.CALIB_BLOCKS + [5000] * 50 + heard, _ = self._run(mock_sd, levels) + assert heard is True + + def test_brief_spike_does_not_trigger(self, mock_sd): + levels = [0] * self.CALIB_BLOCKS + [5000] * (self.TRIP_BLOCKS - 2) + [0] * 500 + heard, _ = self._run(mock_sd, levels) + assert heard is False + + def test_should_stop_wins_over_silence(self, mock_sd): + """TTS finishing (should_stop) ends the monitor without a trigger — + the default _run stopper flips True after 200 silent reads.""" + heard, stream = self._run(mock_sd, [0] * 500) + assert heard is False + assert stream.reads <= 201 + + def test_returns_false_when_audio_unavailable(self, monkeypatch): + monkeypatch.setattr("tools.voice_mode._import_audio", MagicMock(side_effect=OSError("no audio"))) + from tools.voice_mode import listen_for_speech + assert listen_for_speech(lambda: False) is False + + def test_loud_floor_raises_trigger(self, mock_sd): + """Speaker bleed during calibration bakes into the floor — playback-level + audio after calibration must NOT trip (only louder speech does).""" + levels = [2000] * self.CALIB_BLOCKS + [2000] * 100 + heard, _ = self._run(mock_sd, levels) + assert heard is False + + +class TestListenForSpeechCapture: + """capture=True: the barge monitor records the interruption with pre-roll, + so the utterance is complete from its first syllable — nothing is lost + between detection and a recorder restart.""" + + CALIB_BLOCKS = 14 # 400ms / 30ms + LOUD_BLOCKS = 30 # speech: trips after 10, keeps talking + BLOCK = 480 # 16000 * 0.03 + + def _run(self, mock_sd, monkeypatch, levels, should_stop=None, **kwargs): + np = pytest.importorskip("numpy") + stream = _FakeInputStream(np, levels) + mock_sd.InputStream.return_value = stream + written = {} + monkeypatch.setattr( + "tools.voice_mode.AudioRecorder._write_wav", + staticmethod(lambda audio: written.update(audio=audio) or "/tmp/barge.wav"), + ) + from tools.voice_mode import listen_for_speech + stops = iter([False] * 200 + [True] * 10_000) + path = listen_for_speech( + should_stop or (lambda: next(stops)), capture=True, **kwargs + ) + return path, written.get("audio"), stream + + def test_captured_utterance_includes_speech_onset(self, mock_sd, monkeypatch): + """Every loud block — including the ones BEFORE detection tripped — + must land in the WAV. That pre-roll is the whole point.""" + triggered = [] + levels = [0] * self.CALIB_BLOCKS + [5000] * self.LOUD_BLOCKS + [0] * 500 + path, audio, _ = self._run( + mock_sd, monkeypatch, levels, + should_stop=lambda: False, + on_trigger=lambda: triggered.append(True), + ) + assert path == "/tmp/barge.wav" + assert triggered == [True] + assert int((audio == 5000).sum()) == self.LOUD_BLOCKS * self.BLOCK + + def test_no_trip_returns_none(self, mock_sd, monkeypatch): + triggered = [] + path, audio, _ = self._run( + mock_sd, monkeypatch, [0] * 500, + on_trigger=lambda: triggered.append(True), + ) + assert path is None + assert audio is None + assert triggered == [] + + def test_returns_none_when_audio_unavailable(self, monkeypatch): + monkeypatch.setattr("tools.voice_mode._import_audio", MagicMock(side_effect=OSError("no audio"))) + from tools.voice_mode import listen_for_speech + assert listen_for_speech(lambda: False, capture=True) is None diff --git a/tools/voice_mode.py b/tools/voice_mode.py index d000e29d59d9..065ae747ef98 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -20,7 +20,7 @@ import tempfile import threading import time import wave -from typing import Any, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional logger = logging.getLogger(__name__) @@ -900,6 +900,12 @@ def transcribe_recording(wav_path: str, model: Optional[str] = None) -> Dict[str logger.info("Filtered Whisper hallucination: %r", result["transcript"]) return {"success": True, "transcript": "", "filtered": True} + # Providers that flag no_speech (empty transcript) failed to hear words, + # not to transcribe — treat like silence so the voice loop re-listens + # quietly instead of surfacing "Transcription failed". + if result.get("no_speech"): + return {"success": True, "transcript": "", "no_speech": True} + return result @@ -1118,6 +1124,94 @@ def play_audio_file(file_path: str) -> bool: return False +# ============================================================================ +# Barge-in — detect the user speaking over TTS playback +# ============================================================================ +def listen_for_speech( + should_stop: Callable[[], bool], + threshold: Optional[int] = None, + sustained_ms: int = 300, + calibration_ms: int = 400, + capture: bool = False, + on_trigger: Optional[Callable[[], None]] = None, + pre_roll_ms: int = 1200, + endpoint_silence_ms: int = 1250, + max_utterance_ms: int = 30_000, +): + """Block until sustained speech is heard on the mic, or *should_stop*. + + Barge-in monitor: run in a side thread while TTS is playing. Without + *capture* it returns ``True`` when the user started talking (cut playback). + With ``capture=True`` it ALSO records the interruption — a rolling + *pre_roll_ms* buffer means the utterance is kept from its first syllable, + not from the moment detection tripped — and keeps rolling until the user + goes quiet for *endpoint_silence_ms*, then returns the WAV path (or + ``None`` if speech never tripped). *on_trigger* fires at the moment of + detection so the caller can stop playback while capture continues. + + The noise floor is calibrated from the first *calibration_ms* of input — + playback is already audible then, so speaker bleed is baked into the + floor and only louder-than-playback speech trips the trigger. Requiring + *sustained_ms* of consecutive above-threshold blocks filters out coughs, + keyboard thumps, and playback transients. + """ + try: + sd, np = _import_audio() + except (ImportError, OSError): + return None if capture else False + + from collections import deque + + block = int(SAMPLE_RATE * 0.03) # 30ms blocks + calib_blocks = max(1, calibration_ms // 30) + trip_blocks = max(1, sustained_ms // 30) + endpoint_blocks = max(1, endpoint_silence_ms // 30) + max_blocks = max(1, max_utterance_ms // 30) + floor_samples: List[float] = [] + pre_roll: deque = deque(maxlen=max(1, pre_roll_ms // 30)) + consecutive = 0 + + try: + with sd.InputStream(samplerate=SAMPLE_RATE, channels=1, dtype="int16", blocksize=block) as stream: + while not should_stop(): + data, _ = stream.read(block) + rms = float(np.sqrt(np.mean(data.astype(np.float64) ** 2))) + if capture: + pre_roll.append(data.copy()) + if len(floor_samples) < calib_blocks: + floor_samples.append(rms) + continue + trigger = max(float(threshold or SILENCE_RMS_THRESHOLD * 2), float(np.median(floor_samples)) * 3.5) + consecutive = consecutive + 1 if rms >= trigger else 0 + if consecutive < trip_blocks: + continue + + # Tripped — the user is talking over playback. + if on_trigger: + try: + on_trigger() + except Exception as e: + logger.debug("Barge-in trigger callback failed: %s", e) + if not capture: + return True + + # Keep rolling until the user goes quiet. Playback is stopped + # now, so plain silence endpointing (recorder threshold) works. + frames: List[Any] = list(pre_roll) + quiet = 0 + for _ in range(max_blocks): + data, _ = stream.read(block) + frames.append(data.copy()) + rms = float(np.sqrt(np.mean(data.astype(np.float64) ** 2))) + quiet = quiet + 1 if rms < SILENCE_RMS_THRESHOLD else 0 + if quiet >= endpoint_blocks: + break + return AudioRecorder._write_wav(np.concatenate(frames, axis=0)) + except Exception as e: + logger.debug("Barge-in listener failed: %s", e) + return None if capture else False + + # ============================================================================ # Requirements check # ============================================================================ From b135a8badd79d022f734a66ac965d27142b3b5e1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 17:47:15 -0500 Subject: [PATCH 050/238] feat(voice): stream any provider in the CLI, with barge-in capture The streaming gate broadens from ElevenLabs-only to any provider that passes check_tts_requirements(). In continuous voice mode a mic monitor runs during playback: talking over the agent cuts TTS at detection while the monitor keeps recording, then the captured interruption is transcribed and queued as the next turn (process_loop's auto-restart stands down while the capture owns the mic). New voice.barge_in config key, default true. --- cli.py | 146 +++++++++++++----- hermes_cli/config.py | 1 + tests/tools/test_voice_cli_integration.py | 178 +++++++++------------- 3 files changed, 189 insertions(+), 136 deletions(-) diff --git a/cli.py b/cli.py index b9641e53ee20..18e167c00971 100644 --- a/cli.py +++ b/cli.py @@ -4205,6 +4205,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self._voice_continuous = False self._voice_tts_done = threading.Event() self._voice_tts_done.set() + self._voice_tts_stop = None # active streaming pipeline's stop event + self._voice_barge_capture = threading.Event() # barge monitor is capturing the interruption # Status bar visibility (toggled via /statusbar) self._status_bar_visible = True @@ -11099,6 +11101,26 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): time.sleep(0.15) threading.Thread(target=_refresh_level, daemon=True).start() + def _voice_stt_model(self) -> Optional[str]: + """STT model override from config, or None for the provider default.""" + try: + from hermes_cli.config import load_config + stt_config = load_config().get("stt", {}) + return stt_config.get("model") if isinstance(stt_config, dict) else None + except Exception: + return None + + def _voice_restart_recording_async(self) -> None: + """Restart continuous-mode recording off-thread (start() can block).""" + def _restart_recording(): + try: + self._voice_start_recording() + if hasattr(self, '_app') and self._app: + self._app.invalidate() + except Exception as e: + _cprint(f"{_DIM}Voice auto-restart failed: {e}{_RST}") + threading.Thread(target=_restart_recording, daemon=True).start() + def _voice_stop_and_transcribe(self): """Stop recording, transcribe via STT, and queue the transcript as input.""" # Atomic guard: only one thread can enter stop-and-transcribe. @@ -11136,17 +11158,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self._app.invalidate() _cprint(f"{_DIM}Transcribing...{_RST}") - # Get STT model from config - stt_model = None - try: - from hermes_cli.config import load_config - stt_config = load_config().get("stt", {}) - stt_model = stt_config.get("model") - except Exception: - pass - from tools.voice_mode import transcribe_recording - result = transcribe_recording(wav_path, model=stt_model) + result = transcribe_recording(wav_path, model=self._voice_stt_model()) if result.get("success") and result.get("transcript", "").strip(): transcript = result["transcript"].strip() @@ -11197,14 +11210,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # (When transcript IS submitted, process_loop handles restart # after chat() completes.) if self._voice_continuous and not submitted and not self._voice_recording: - def _restart_recording(): - try: - self._voice_start_recording() - if hasattr(self, '_app') and self._app: - self._app.invalidate() - except Exception as e: - _cprint(f"{_DIM}Voice auto-restart failed: {e}{_RST}") - threading.Thread(target=_restart_recording, daemon=True).start() + self._voice_restart_recording_async() def _voice_speak_response_async(self, text: str) -> None: """Schedule TTS and mark it pending before continuous recording can restart.""" @@ -11270,6 +11276,68 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self._voice_tts_done.set() + def _voice_barge_in_monitor(self, stop_event: threading.Event) -> None: + """VAD barge-in: cut streaming TTS the moment the user starts talking. + + Runs for one turn alongside the streaming pipeline (continuous voice + mode only — the mic is otherwise idle during playback). On speech, + playback is cut immediately while the monitor KEEPS capturing (with + pre-roll, so the interruption is transcribed from its first syllable + — restarting the recorder after detection would lose the opening + words). ``_voice_barge_capture`` suppresses process_loop's auto- + restart until the captured utterance has been submitted. + """ + try: + from hermes_cli.config import load_config + voice_cfg = load_config().get("voice") or {} + if not (isinstance(voice_cfg, dict) and voice_cfg.get("barge_in", True)): + return + from tools.voice_mode import listen_for_speech, stop_playback + + def _cut_playback(): + if not self._voice_tts_done.is_set(): + self._voice_barge_capture.set() + stop_event.set() + stop_playback() + + wav_path = listen_for_speech( + lambda: stop_event.is_set() or self._voice_tts_done.is_set(), + capture=True, + on_trigger=_cut_playback, + ) + if wav_path and self._voice_barge_capture.is_set(): + self._voice_submit_barge_utterance(wav_path) + else: + self._voice_barge_capture.clear() + except Exception as e: + self._voice_barge_capture.clear() + logger.debug("Voice barge-in monitor failed: %s", e) + + def _voice_submit_barge_utterance(self, wav_path: str) -> None: + """Transcribe a barge-captured interruption and queue it as the next turn.""" + submitted = False + try: + from tools.voice_mode import transcribe_recording + result = transcribe_recording(wav_path, model=self._voice_stt_model()) + transcript = (result.get("transcript") or "").strip() if result.get("success") else "" + if transcript: + self._pending_input.put(transcript) + submitted = True + elif not result.get("success"): + _cprint(f"\n{_DIM}Transcription failed: {result.get('error', 'Unknown error')}{_RST}") + except Exception as e: + _cprint(f"\n{_DIM}Voice processing error: {e}{_RST}") + finally: + try: + if os.path.isfile(wav_path): + os.unlink(wav_path) + except OSError: + pass + self._voice_barge_capture.clear() + # No usable transcript: hand the mic back to the normal loop. + if not submitted and self._voice_mode and self._voice_continuous and not self._voice_recording: + self._voice_restart_recording_async() + def _voice_beeps_enabled(self) -> bool: """Return whether CLI voice mode should play record start/stop beeps.""" try: @@ -11363,8 +11431,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): threading.Thread(target=_bg_shutdown, daemon=True).start() self._voice_recorder = None - # Stop any active TTS playback + # Stop any active TTS playback (file player + streaming pipeline) try: + if self._voice_tts_stop is not None: + self._voice_tts_stop.set() from tools.voice_mode import stop_playback stop_playback() except Exception: @@ -12117,9 +12187,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self._reasoning_shown_this_turn = False # --- Streaming TTS setup --- - # When ElevenLabs is the TTS provider and sounddevice is available, - # we stream audio sentence-by-sentence as the agent generates tokens - # instead of waiting for the full response. + # Any working TTS provider streams sentence-by-sentence as the agent + # generates tokens: PCM-streaming providers (ElevenLabs, OpenAI) play + # chunks as they arrive, everything else synthesizes per sentence. use_streaming_tts = False _streaming_box_opened = False text_queue = None @@ -12130,20 +12200,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): if self._voice_tts: try: from tools.tts_tool import ( - _load_tts_config as _load_tts_cfg, - _get_provider as _get_prov, - _import_elevenlabs, _import_sounddevice, + check_tts_requirements, stream_tts_to_speaker, ) - _tts_cfg = _load_tts_cfg() - if _get_prov(_tts_cfg) == "elevenlabs": - # Verify both ElevenLabs SDK and audio output are available - _import_elevenlabs() - _import_sounddevice() - use_streaming_tts = True - except (ImportError, OSError): - pass + _import_sounddevice() + use_streaming_tts = check_tts_requirements() except Exception: pass @@ -12171,6 +12233,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): daemon=True, ) tts_thread.start() + # Expose the pipeline's stop event so barge-in paths (voice + # key, VAD monitor) can cut playback from outside this turn. + self._voice_tts_stop = stop_event + if self._voice_continuous: + threading.Thread( + target=self._voice_barge_in_monitor, args=(stop_event,), daemon=True + ).start() def stream_callback(delta: str): if text_queue is not None: @@ -13316,6 +13385,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self._voice_continuous = False # Whether to auto-restart after agent responds self._voice_tts_done = threading.Event() # Signals TTS playback finished self._voice_tts_done.set() # Initially "done" (no TTS pending) + self._voice_tts_stop = None # active streaming pipeline's stop event + self._voice_barge_capture = threading.Event() # barge monitor is capturing the interruption if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1": self._install_tool_callbacks() @@ -14104,9 +14175,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): return # Interrupt TTS if playing, so user can start talking. - # stop_playback() is fast (just terminates a subprocess). + # stop_playback() is fast (just terminates a subprocess); + # the stop event drains the streaming pipeline if one is live. if not cli_ref._voice_tts_done.is_set(): try: + if cli_ref._voice_tts_stop is not None: + cli_ref._voice_tts_stop.set() from tools.voice_mode import stop_playback stop_playback() cli_ref._voice_tts_done.set() @@ -15328,6 +15402,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): if self._voice_tts: self._voice_tts_done.wait(timeout=60) time.sleep(0.3) + # A barge-in capture already owns the mic and + # will submit the interruption itself. + if self._voice_barge_capture.is_set(): + return self._voice_start_recording() app.invalidate() except Exception as e: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index fd6de3a58dbb..fb538fcf3059 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2320,6 +2320,7 @@ DEFAULT_CONFIG = { "beep_enabled": True, # Play record start/stop beeps in CLI voice mode "silence_threshold": 200, # RMS below this = silence (0-32767) "silence_duration": 3.0, # Seconds of silence before auto-stop + "barge_in": True, # Stop TTS playback when the user starts talking }, "human_delay": { diff --git a/tests/tools/test_voice_cli_integration.py b/tests/tools/test_voice_cli_integration.py index bf96ff501281..1e566f74806f 100644 --- a/tests/tools/test_voice_cli_integration.py +++ b/tests/tools/test_voice_cli_integration.py @@ -29,6 +29,8 @@ def _make_voice_cli(**overrides): cli._voice_continuous = False cli._voice_tts_done = threading.Event() cli._voice_tts_done.set() + cli._voice_tts_stop = None + cli._voice_barge_capture = threading.Event() cli._pending_input = queue.Queue() cli._app = None cli._attached_images = [] @@ -176,114 +178,40 @@ class TestVoiceStateLock: # ============================================================================ class TestStreamingTTSActivation: - """Verify streaming TTS uses lazy imports to check availability.""" + """The CLI streaming gate: sounddevice + a working provider, ANY provider. - def test_activates_when_elevenlabs_and_sounddevice_available(self): - """use_streaming_tts should be True when provider is elevenlabs - and both lazy imports succeed.""" - use_streaming_tts = False + Mirrors cli.py's gate exactly — streaming engages whenever audio output + exists and check_tts_requirements() passes, regardless of which provider + is configured (non-streamers get the per-sentence sync path downstream). + """ + + @staticmethod + def _gate() -> bool: + """The cli.py streaming-TTS gate, verbatim.""" try: - from tools.tts_tool import ( - _load_tts_config as _load_tts_cfg, - _get_provider as _get_prov, - _import_elevenlabs, - _import_sounddevice, - ) - assert callable(_import_elevenlabs) - assert callable(_import_sounddevice) - except ImportError: - pytest.skip("tools.tts_tool not available") + from tools.tts_tool import _import_sounddevice, check_tts_requirements + _import_sounddevice() + return check_tts_requirements() + except Exception: + return False - with patch("tools.tts_tool._load_tts_config") as mock_cfg, \ - patch("tools.tts_tool._get_provider", return_value="elevenlabs"), \ - patch("tools.tts_tool._import_elevenlabs") as mock_el, \ - patch("tools.tts_tool._import_sounddevice") as mock_sd: - mock_cfg.return_value = {"provider": "elevenlabs"} - mock_el.return_value = MagicMock() - mock_sd.return_value = MagicMock() + def test_activates_for_any_working_provider(self): + """Any provider that passes check_tts_requirements engages streaming.""" + with patch("tools.tts_tool._import_sounddevice", return_value=MagicMock()), \ + patch("tools.tts_tool.check_tts_requirements", return_value=True): + assert self._gate() is True - from tools.tts_tool import ( - _load_tts_config as load_cfg, - _get_provider as get_prov, - _import_elevenlabs as import_el, - _import_sounddevice as import_sd, - ) - cfg = load_cfg() - if get_prov(cfg) == "elevenlabs": - import_el() - import_sd() - use_streaming_tts = True - - assert use_streaming_tts is True - - def test_does_not_activate_when_elevenlabs_missing(self): - """use_streaming_tts stays False when elevenlabs import fails.""" - use_streaming_tts = False - with patch("tools.tts_tool._load_tts_config", return_value={"provider": "elevenlabs"}), \ - patch("tools.tts_tool._get_provider", return_value="elevenlabs"), \ - patch("tools.tts_tool._import_elevenlabs", side_effect=ImportError("no elevenlabs")): - try: - from tools.tts_tool import ( - _load_tts_config as load_cfg, - _get_provider as get_prov, - _import_elevenlabs as import_el, - _import_sounddevice as import_sd, - ) - cfg = load_cfg() - if get_prov(cfg) == "elevenlabs": - import_el() - import_sd() - use_streaming_tts = True - except (ImportError, OSError): - pass - - assert use_streaming_tts is False + def test_does_not_activate_when_provider_unavailable(self): + """No working TTS provider → no streaming pipeline.""" + with patch("tools.tts_tool._import_sounddevice", return_value=MagicMock()), \ + patch("tools.tts_tool.check_tts_requirements", return_value=False): + assert self._gate() is False def test_does_not_activate_when_sounddevice_missing(self): - """use_streaming_tts stays False when sounddevice import fails.""" - use_streaming_tts = False - with patch("tools.tts_tool._load_tts_config", return_value={"provider": "elevenlabs"}), \ - patch("tools.tts_tool._get_provider", return_value="elevenlabs"), \ - patch("tools.tts_tool._import_elevenlabs", return_value=MagicMock()), \ - patch("tools.tts_tool._import_sounddevice", side_effect=OSError("no PortAudio")): - try: - from tools.tts_tool import ( - _load_tts_config as load_cfg, - _get_provider as get_prov, - _import_elevenlabs as import_el, - _import_sounddevice as import_sd, - ) - cfg = load_cfg() - if get_prov(cfg) == "elevenlabs": - import_el() - import_sd() - use_streaming_tts = True - except (ImportError, OSError): - pass - - assert use_streaming_tts is False - - def test_does_not_activate_for_non_elevenlabs_provider(self): - """use_streaming_tts stays False when provider is not elevenlabs.""" - use_streaming_tts = False - with patch("tools.tts_tool._load_tts_config", return_value={"provider": "edge"}), \ - patch("tools.tts_tool._get_provider", return_value="edge"): - try: - from tools.tts_tool import ( - _load_tts_config as load_cfg, - _get_provider as get_prov, - _import_elevenlabs as import_el, - _import_sounddevice as import_sd, - ) - cfg = load_cfg() - if get_prov(cfg) == "elevenlabs": - import_el() - import_sd() - use_streaming_tts = True - except (ImportError, OSError): - pass - - assert use_streaming_tts is False + """No audio output device → no streaming pipeline, even with a provider.""" + with patch("tools.tts_tool._import_sounddevice", side_effect=OSError("no PortAudio")), \ + patch("tools.tts_tool.check_tts_requirements", return_value=True): + assert self._gate() is False def test_stale_boolean_imports_no_longer_exist(self): """Confirm _HAS_ELEVENLABS and _HAS_AUDIO are not in tts_tool module.""" @@ -1326,3 +1254,49 @@ class TestRefreshLevelLock: assert not t.is_alive() assert not t.is_alive(), "Refresh thread did not stop" assert iterations > 0, "Refresh thread never ran" + + +# --------------------------------------------------------------------------- +# Barge-in capture — the interruption is transcribed and queued directly +# --------------------------------------------------------------------------- + + +class TestVoiceBargeCaptureSubmit: + """_voice_submit_barge_utterance: the barge monitor's captured WAV becomes + the next turn without a re-record round trip.""" + + def test_transcript_is_queued_and_wav_removed(self, tmp_path, monkeypatch): + cli = _make_voice_cli() + cli._voice_barge_capture.set() + wav = tmp_path / "barge.wav" + wav.write_bytes(b"RIFF") + + monkeypatch.setattr( + "tools.voice_mode.transcribe_recording", + lambda path, model=None: {"success": True, "transcript": "stop, do it differently"}, + ) + + cli._voice_submit_barge_utterance(str(wav)) + + assert cli._pending_input.get_nowait() == "stop, do it differently" + assert not cli._voice_barge_capture.is_set() + assert not wav.exists() + + def test_no_speech_hands_mic_back_without_queueing(self, tmp_path, monkeypatch): + cli = _make_voice_cli(_voice_mode=True, _voice_continuous=True) + cli._voice_barge_capture.set() + wav = tmp_path / "barge.wav" + wav.write_bytes(b"RIFF") + restarted = threading.Event() + cli._voice_start_recording = lambda: restarted.set() + + monkeypatch.setattr( + "tools.voice_mode.transcribe_recording", + lambda path, model=None: {"success": True, "transcript": "", "no_speech": True}, + ) + + cli._voice_submit_barge_utterance(str(wav)) + + assert cli._pending_input.empty() + assert not cli._voice_barge_capture.is_set() + assert restarted.wait(2.0) # continuous mode resumes listening From 68e1fedd2d0d0bbc2ac5c40feeee3345a883bf42 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 17:47:15 -0500 Subject: [PATCH 051/238] feat(voice): stream turn deltas through the TUI gateway, with barge-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn deltas feed a per-turn TTS pipeline; the post-complete speak_text call survives only as a fallback. session.interrupt, /voice toggles, and new turns cut in-flight speech. VAD barge-in emits voice.interrupted at detection, then the captured interruption goes out as voice.transcript — the same event the TUI already submits as a spoken turn. --- tests/test_tui_gateway_server.py | 129 ++++++++++++++++++++++++++++++- tui_gateway/server.py | 128 ++++++++++++++++++++++++++++-- 2 files changed, 251 insertions(+), 6 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 9a197f13acd6..e5169339c8c2 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1209,7 +1209,10 @@ def test_voice_toggle_tts_branch_also_carries_record_key(monkeypatch): ), ) monkeypatch.setenv("HERMES_VOICE", "1") - monkeypatch.delenv("HERMES_VOICE_TTS", raising=False) + # setenv (not delenv) — the handler writes HERMES_VOICE_TTS directly, and + # delenv on an absent var registers no teardown, leaking TTS=1 into every + # later test in the file (which now spins up the streaming TTS pipeline). + monkeypatch.setenv("HERMES_VOICE_TTS", "0") tts_resp = server.dispatch( {"id": "voice-tts", "method": "voice.toggle", "params": {"action": "tts"}} @@ -11733,3 +11736,127 @@ def test_get_usage_clamps_post_compression_sentinel(): usage = server._get_usage(agent) assert "context_used" not in usage assert "context_percent" not in usage + + +# --------------------------------------------------------------------------- +# Streaming TTS — per-turn pipeline + barge-in +# --------------------------------------------------------------------------- + +def _fake_tts_modules(monkeypatch, *, requirements=True, playback_stops=None, listen=None, transcribe=None): + """Install lightweight tools.tts_tool / tools.voice_mode fakes.""" + started = {} + + def fake_stream(text_queue, stop, done, **_kw): + started["queue"] = text_queue + stop.wait(5) + done.set() + + def default_listen(should_stop, capture=False, on_trigger=None, **_kw): + return None if capture else False + + monkeypatch.setitem( + sys.modules, + "tools.tts_tool", + types.SimpleNamespace( + check_tts_requirements=lambda: requirements, + stream_tts_to_speaker=fake_stream, + ), + ) + monkeypatch.setitem( + sys.modules, + "tools.voice_mode", + types.SimpleNamespace( + stop_playback=lambda: (playback_stops.append(True) if playback_stops is not None else None), + listen_for_speech=listen or default_listen, + transcribe_recording=transcribe or (lambda path, model=None: {"success": True, "transcript": ""}), + ), + ) + return started + + +def test_tts_stream_begin_requires_voice_tts(monkeypatch): + monkeypatch.setenv("HERMES_VOICE_TTS", "0") + assert server._tts_stream_begin() is None + + +def test_tts_stream_begin_requires_working_provider(monkeypatch): + monkeypatch.setenv("HERMES_VOICE_TTS", "1") + _fake_tts_modules(monkeypatch, requirements=False) + assert server._tts_stream_begin() is None + + +def test_tts_stream_begin_and_stop_lifecycle(monkeypatch): + """begin() spawns the consumer; stop() cuts it and clears the slot.""" + monkeypatch.setenv("HERMES_VOICE_TTS", "1") + monkeypatch.setenv("HERMES_VOICE", "0") # no barge-in monitor (no mic) + playback_stops: list = [] + started = _fake_tts_modules(monkeypatch, playback_stops=playback_stops) + + text_queue = server._tts_stream_begin() + assert text_queue is not None + assert started["queue"] is text_queue + + with server._tts_stream_lock: + state = server._tts_stream_state + assert state is not None and not state["stop"].is_set() + + server._tts_stream_stop() + assert state["stop"].is_set() + assert playback_stops == [True] + with server._tts_stream_lock: + assert server._tts_stream_state is None + + +def test_tts_stream_begin_barges_in_on_previous_pipeline(monkeypatch): + """A new turn's pipeline stops the previous turn's speech (one speaker).""" + monkeypatch.setenv("HERMES_VOICE_TTS", "1") + monkeypatch.setenv("HERMES_VOICE", "0") + _fake_tts_modules(monkeypatch) + + server._tts_stream_begin() + with server._tts_stream_lock: + first = server._tts_stream_state + server._tts_stream_begin() + assert first is not None and first["stop"].is_set() + server._tts_stream_stop() + + +def test_tts_stream_vad_barge_in_cuts_pipeline_and_submits_capture(monkeypatch, tmp_path): + """User speech during playback cuts TTS at the moment of detection + (voice.interrupted), then the captured interruption is transcribed and + emitted as voice.transcript so the TUI submits it — complete from its + first syllable, no re-record round trip.""" + monkeypatch.setenv("HERMES_VOICE_TTS", "1") + monkeypatch.setenv("HERMES_VOICE", "1") + monkeypatch.setattr(server, "_load_cfg", lambda: {"voice": {"barge_in": True}}) + events: list = [] + monkeypatch.setattr( + server, "_voice_emit", lambda event, payload=None: events.append((event, payload)) + ) + + wav = tmp_path / "barge.wav" + wav.write_bytes(b"RIFF") + + def fake_listen(should_stop, capture=False, on_trigger=None, **_kw): + assert capture is True + on_trigger() # playback cut happens at detection, not after endpointing + return str(wav) + + _fake_tts_modules( + monkeypatch, + listen=fake_listen, + transcribe=lambda path, model=None: {"success": True, "transcript": "stop, actually—"}, + ) + + server._tts_stream_begin() + with server._tts_stream_lock: + state = server._tts_stream_state + assert state is not None + assert state["stop"].wait(2.0) + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline and wav.exists(): + time.sleep(0.01) # unlink (finally) runs after the transcript emit + assert ("voice.interrupted", None) in events + assert ("voice.transcript", {"text": "stop, actually—"}) in events + assert not wav.exists() # capture temp file cleaned up + server._tts_stream_stop() diff --git a/tui_gateway/server.py b/tui_gateway/server.py index f10eba7fdddc..ba7372673835 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -9372,6 +9372,9 @@ def _(rid, params: dict) -> dict: @method("session.interrupt") def _(rid, params: dict) -> dict: + # Keypress barge-in: stopping the turn also silences its streaming TTS + # (voice is process-global, so no per-session scoping is needed). + _tts_stream_stop() session, err = _sess_nowait(params, rid) if err: return err @@ -10302,6 +10305,7 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: session_tokens = [] home_token = None # per-turn HERMES_HOME override for a resumed remote profile goal_followup = None # set by the post-turn goal hook below + tts_queue = None # streaming-TTS feed for this turn (voice mode) one_turn_restore = session.pop("one_turn_model_restore", None) try: from tools.approval import ( @@ -10424,12 +10428,18 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: else: run_message = _enrich_with_attached_images(prompt, images) + # Streaming TTS: voice-mode replies are spoken sentence-by-sentence + # as tokens arrive (CLI parity) instead of after the full turn. + tts_queue = _tts_stream_begin() + def _stream(delta): with session["history_lock"]: _append_inflight_delta(session, delta) payload = {"text": delta} if streamer and (r := streamer.feed(delta)) is not None: payload["rendered"] = r + if tts_queue is not None and isinstance(delta, str): + tts_queue.put(delta) _emit("message.delta", sid, payload) # Surface interim assistant text (commentary emitted alongside @@ -10698,12 +10708,13 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: except Exception: pass - # CLI parity: when voice-mode TTS is on, speak the agent reply - # (cli.py:_voice_speak_response). Only the final text — tool - # calls / reasoning already stream separately and would be - # noisy to read aloud. + # Voice TTS fallback: when the streaming pipeline couldn't start + # (no provider / missing deps probed at turn start), speak the + # final text whole (cli.py:_voice_speak_response parity). The + # streaming path already spoke everything via tts_queue. if ( status == "complete" + and tts_queue is None and isinstance(raw, str) and raw.strip() and _voice_tts_enabled() @@ -10738,6 +10749,8 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: ) _emit("error", sid, {"message": str(e)}) finally: + if tts_queue is not None: + tts_queue.put(None) # end-of-text sentinel — flush + finish speaking if one_turn_restore: try: _restore_agent_model_runtime(agent, one_turn_restore) @@ -15512,6 +15525,107 @@ def _voice_tts_enabled() -> bool: return os.environ.get("HERMES_VOICE_TTS", "").strip() == "1" +# ── Streaming TTS (one active pipeline per process — one speaker) ────────── +# Token deltas from the running turn feed a sentence-buffering consumer +# (tools.tts_tool.stream_tts_to_speaker) so speech starts on the first +# sentence instead of after the full reply. Voice is process-global, so a +# single slot suffices; starting a new turn's pipeline barges in on the +# previous one. + +_tts_stream_lock = threading.Lock() +_tts_stream_state: Optional[dict] = None + + +def _tts_stream_begin() -> Optional[queue.Queue]: + """Start a per-turn streaming TTS consumer; None when TTS can't stream.""" + if not _voice_tts_enabled(): + return None + try: + from tools.tts_tool import check_tts_requirements, stream_tts_to_speaker + + if not check_tts_requirements(): + return None + except Exception: + return None + + _tts_stream_stop() + text_queue: queue.Queue = queue.Queue() + stop = threading.Event() + done = threading.Event() + threading.Thread( + target=stream_tts_to_speaker, args=(text_queue, stop, done), daemon=True + ).start() + + global _tts_stream_state + with _tts_stream_lock: + _tts_stream_state = {"stop": stop, "done": done} + + if _voice_mode_enabled() and _voice_cfg_dict().get("barge_in", True): + threading.Thread( + target=_tts_stream_barge_in_monitor, args=(stop, done), daemon=True + ).start() + + return text_queue + + +def _tts_stream_stop() -> None: + """Barge-in: cut any in-flight streaming TTS (new turn, interrupt, /voice off).""" + global _tts_stream_state + with _tts_stream_lock: + state, _tts_stream_state = _tts_stream_state, None + if state is None: + return + state["stop"].set() + try: + from tools.voice_mode import stop_playback + + stop_playback() + except Exception: + pass + + +def _tts_stream_barge_in_monitor(stop: threading.Event, done: threading.Event) -> None: + """VAD barge-in: cut streaming TTS when the user starts talking. + + Playback is cut at the moment of detection while the monitor keeps + capturing (with pre-roll) until the user goes quiet — the interruption is + then transcribed and emitted as ``voice.transcript``, which the TUI + submits like any spoken turn. Without capture the opening words would be + lost between detection and the next recording start. + """ + try: + from tools.voice_mode import listen_for_speech, stop_playback, transcribe_recording + + barged = threading.Event() + + def _cut_playback(): + if not done.is_set(): + barged.set() + stop.set() + stop_playback() + _voice_emit("voice.interrupted") + + wav_path = listen_for_speech( + lambda: stop.is_set() or done.is_set(), + capture=True, + on_trigger=_cut_playback, + ) + if not (wav_path and barged.is_set()): + return + try: + result = transcribe_recording(wav_path) + text = (result.get("transcript") or "").strip() if result.get("success") else "" + if text: + _voice_emit("voice.transcript", {"text": text}) + finally: + try: + os.unlink(wav_path) + except OSError: + pass + except Exception as e: + logger.debug("TTS barge-in monitor failed: %s", e) + + def _voice_cfg_dict() -> dict: """Shape-safe accessor for the ``voice:`` block in config.yaml. @@ -15599,8 +15713,10 @@ def _(rid, params: dict) -> dict: except Exception as e: logger.warning("voice: stop_continuous failed during toggle off: %s", e) - # Clear TTS so it can be toggled independently after voice is off. + # Clear TTS so it can be toggled independently after voice is off, + # and silence any in-flight streaming speech. os.environ["HERMES_VOICE_TTS"] = "0" + _tts_stream_stop() return _ok( rid, @@ -15617,6 +15733,8 @@ def _(rid, params: dict) -> dict: new_value = not _voice_tts_enabled() # Runtime-only flag (CLI parity) — see voice.toggle on/off above. os.environ["HERMES_VOICE_TTS"] = "1" if new_value else "0" + if not new_value: + _tts_stream_stop() # Include ``record_key`` on every branch so a /voice tts toggle # doesn't reset the TUI's cached shortcut to the default when a # user has a custom binding configured (Copilot review, round 2 From 93e9061f153f4e7a06111d097a7933648b9851b6 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 17:47:25 -0500 Subject: [PATCH 052/238] feat(voice): desktop speech-stream sessions with barge-in capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/audio/speak-stream WebSocket: one socket + one Web Audio clock per reply. The renderer feeds raw LLM deltas as they arrive; the server cuts sentences with the shared chunker and streams int16 PCM back while generation continues — speech overlaps generation with no per-sentence connection or synthesis gaps. Falls back to the POST data-URL path for old backends / non-chunked providers. Barge-in runs a MediaRecorder on the monitor's stream the whole time playback is live (rotated while quiet to bound pre-roll); talking over the agent cuts playback and the complete utterance goes straight to transcription and submit. /api/audio/transcribe returns 200/"" for no-speech results so quiet turns re-listen instead of toasting a 400. --- .../composer/hooks/use-voice-conversation.ts | 356 +++++++++++----- .../hooks/use-prompt-actions/submit.ts | 6 + apps/desktop/src/lib/voice-barge-in.ts | 238 +++++++++++ apps/desktop/src/lib/voice-playback.ts | 396 +++++++++++++++--- hermes_cli/web_server.py | 155 ++++++- tests/hermes_cli/test_web_server.py | 32 +- .../test_web_server_speak_stream.py | 163 +++++++ 7 files changed, 1185 insertions(+), 161 deletions(-) create mode 100644 apps/desktop/src/lib/voice-barge-in.ts create mode 100644 tests/hermes_cli/test_web_server_speak_stream.py diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts index a8725cac666c..dc8e7dcdf138 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts @@ -1,7 +1,13 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useI18n } from '@/i18n' -import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback' +import { monitorSpeechDuringPlayback } from '@/lib/voice-barge-in' +import { + playSpeechText, + type SpeechStreamSession, + startSpeechStream, + stopVoicePlayback +} from '@/lib/voice-playback' import { notify, notifyError } from '@/store/notifications' import { useMicRecorder } from './use-mic-recorder' @@ -44,7 +50,9 @@ export function useVoiceConversation({ const awaitingSpokenResponseRef = useRef(false) const responseIdRef = useRef(null) const spokenSourceLengthRef = useRef(0) - const speechBufferRef = useRef('') + const speechSessionRef = useRef(null) + const stopBargeMonitorRef = useRef<(() => void) | null>(null) + const bargeCapturePendingRef = useRef(false) const enabledRef = useRef(enabled) const mutedRef = useRef(muted) const busyRef = useRef(busy) @@ -74,60 +82,13 @@ export function useVoiceConversation({ } } - const resetSpeechBuffer = () => { + const dropSpeechSession = () => { + stopBargeMonitorRef.current?.() + stopBargeMonitorRef.current = null + bargeCapturePendingRef.current = false + speechSessionRef.current = null responseIdRef.current = null spokenSourceLengthRef.current = 0 - speechBufferRef.current = '' - } - - const appendSpeechText = (text: string) => { - if (!text) { - return - } - - speechBufferRef.current = `${speechBufferRef.current}${text}` - } - - const takeSpeechChunk = (force = false): string | null => { - const buffer = speechBufferRef.current.replace(/\s+/g, ' ').trim() - - if (!buffer) { - speechBufferRef.current = '' - - return null - } - - const sentence = buffer.match(/^(.+?[.!?。!?])(?:\s+|$)/) - - if (sentence?.[1] && (sentence[1].length >= 8 || force)) { - const chunk = sentence[1].trim() - speechBufferRef.current = buffer.slice(sentence[1].length).trim() - - return chunk - } - - if (!force && buffer.length > 220) { - const softBoundary = Math.max( - buffer.lastIndexOf(', ', 180), - buffer.lastIndexOf('; ', 180), - buffer.lastIndexOf(': ', 180) - ) - - if (softBoundary > 80) { - const chunk = buffer.slice(0, softBoundary + 1).trim() - speechBufferRef.current = buffer.slice(softBoundary + 1).trim() - - return chunk - } - } - - if (!force) { - return null - } - - speechBufferRef.current = '' - - return buffer } const handleTurn = useCallback( @@ -167,7 +128,7 @@ export function useVoiceConversation({ } awaitingSpokenResponseRef.current = true - resetSpeechBuffer() + dropSpeechSession() await onSubmit(transcript) setStatus('thinking') } catch (error) { @@ -193,6 +154,10 @@ export function useVoiceConversation({ return } + if (bargeCapturePendingRef.current) { + return // the barge monitor is mid-capture and owns the mic + } + if (statusRef.current !== 'idle') { return } @@ -220,24 +185,237 @@ export function useVoiceConversation({ } }, [handle, handleTurn, onFatalError, voiceCopy.couldNotStartSession, voiceCopy.microphoneFailed]) - const speak = useCallback( - async (text: string) => { - setStatus('speaking') + const settleAfterSpeech = useCallback( + (barged: boolean) => { + if (barged || !awaitingSpokenResponseRef.current) { + awaitingSpokenResponseRef.current = false + consumePendingResponse() + } + + if (bargeCapturePendingRef.current) { + // The barge monitor is still capturing the user's interruption — it + // owns the next turn. Keep it alive and don't re-open the mic; the + // utterance callback transcribes and submits when they go quiet. + speechSessionRef.current = null + responseIdRef.current = null + spokenSourceLengthRef.current = 0 + setStatus('listening') + + return + } + + dropSpeechSession() + + if (enabledRef.current) { + pendingStartRef.current = true + } + + setStatus('idle') + }, + [consumePendingResponse] + ) + + /** + * Submit the utterance the barge monitor captured — the user's interruption + * from its first syllable, no re-listen round trip. Empty/failed captures + * fall back to normal listening. + */ + const submitCapturedUtterance = useCallback( + async (audio: Blob | null) => { + const resumeListening = () => { + if (enabledRef.current && !mutedRef.current) { + pendingStartRef.current = true + } + + setStatus('idle') + } + + if (!audio || !onTranscribeAudio) { + resumeListening() + + return + } + + setStatus('transcribing') try { - await playSpeechText(text, { source: 'voice-conversation' }) - } catch (error) { - notifyError(error, voiceCopy.playbackFailed) - } finally { - if (enabledRef.current) { - pendingStartRef.current = true - setStatus('idle') - } else { - setStatus('idle') + const transcript = (await onTranscribeAudio(audio)).trim() + + if (!transcript) { + resumeListening() + + return } + + awaitingSpokenResponseRef.current = true + dropSpeechSession() + consumePendingResponse() + await onSubmit(transcript) + setStatus('thinking') + } catch (error) { + notifyError(error, voiceCopy.transcriptionFailed) + resumeListening() } }, - [voiceCopy.playbackFailed] + [consumePendingResponse, onSubmit, onTranscribeAudio, voiceCopy.transcriptionFailed] + ) + + /** Barge-in monitor wiring shared by the live and fallback speech paths. */ + const openBargeMonitor = useCallback( + (onBarge: () => void) => + monitorSpeechDuringPlayback({ + onSpeech: () => { + bargeCapturePendingRef.current = true + onBarge() + stopVoicePlayback() + }, + onUtterance: audio => { + bargeCapturePendingRef.current = false + stopBargeMonitorRef.current = null + void submitCapturedUtterance(audio) + } + }), + [submitCapturedUtterance] + ) + + /** Push any new reply text into the live session; finish when complete. */ + const feedSpeechSession = useCallback( + (responseId: string) => { + const session = speechSessionRef.current + + if (!session || responseIdRef.current !== responseId) { + return + } + + const response = pendingResponse() + + if (response && response.id === responseId) { + if (response.text.length > spokenSourceLengthRef.current) { + session.append(response.text.slice(spokenSourceLengthRef.current)) + spokenSourceLengthRef.current = response.text.length + } + + if (!response.pending && !busyRef.current) { + session.finish() + } + } else if (!busyRef.current) { + // Reply consumed/vanished while we were speaking — close out the turn. + session.finish() + } + }, + [pendingResponse] + ) + + /** Whole-text fallback: wait for the reply to complete, then speak it. */ + const awaitFallbackSpeech = useCallback( + (responseId: string) => { + const poll = () => { + if (responseIdRef.current !== responseId) { + return + } + + const response = pendingResponse() + + if (!response || response.id !== responseId) { + settleAfterSpeech(false) + + return + } + + if (response.pending || busyRef.current) { + window.setTimeout(poll, 250) + + return + } + + let barged = false + + stopBargeMonitorRef.current?.() + stopBargeMonitorRef.current = openBargeMonitor(() => { + barged = true + }) + + void playSpeechText(response.text, { source: 'voice-conversation' }) + .catch(error => notifyError(error, voiceCopy.playbackFailed)) + .finally(() => { + if (responseIdRef.current === responseId) { + awaitingSpokenResponseRef.current = false + settleAfterSpeech(barged) + } + }) + } + + poll() + }, + [openBargeMonitor, pendingResponse, settleAfterSpeech, voiceCopy.playbackFailed] + ) + + /** + * Live-speak the streaming reply: one speech session per response, fed + * incremental text as the assistant generates it. Audio overlaps generation + * — no wait for the full reply, no per-sentence gaps. + */ + const openLiveSpeech = useCallback( + (responseId: string) => { + responseIdRef.current = responseId + spokenSourceLengthRef.current = 0 + setStatus('speaking') + + let barged = false + + // VAD barge-in: the user talking over the reply cuts playback, drops + // the not-yet-spoken remainder, AND keeps capturing — the interruption + // is transcribed from its first syllable instead of losing the opening + // words to a mic re-open. + stopBargeMonitorRef.current = openBargeMonitor(() => { + barged = true + }) + + void (async () => { + const session = await startSpeechStream({ source: 'voice-conversation' }) + + // The session may resolve after the loop moved on (barge, disable). + if (responseIdRef.current !== responseId) { + if (session) { + stopVoicePlayback() + } + + return + } + + if (!session) { + // No streaming backend/provider: speak the whole reply once it lands. + speechSessionRef.current = null + awaitFallbackSpeech(responseId) + + return + } + + speechSessionRef.current = session + + // Timer-driven feed: reply text flows into the session at delta rate + // regardless of React render cadence. + const feedTimer = window.setInterval(() => feedSpeechSession(responseId), 150) + feedSpeechSession(responseId) + + const outcome = await session.done + window.clearInterval(feedTimer) + + if (responseIdRef.current !== responseId) { + return + } + + if (outcome === 'fallback') { + awaitFallbackSpeech(responseId) + + return + } + + awaitingSpokenResponseRef.current = false + settleAfterSpeech(barged) + })() + }, + [awaitFallbackSpeech, feedSpeechSession, openBargeMonitor, settleAfterSpeech] ) const start = useCallback(async () => { @@ -254,7 +432,7 @@ export function useVoiceConversation({ setMuted(false) awaitingSpokenResponseRef.current = false - resetSpeechBuffer() + dropSpeechSession() consumePendingResponse() pendingStartRef.current = true await startListening() @@ -274,7 +452,7 @@ export function useVoiceConversation({ handle.cancel() turnClosingRef.current = false awaitingSpokenResponseRef.current = false - resetSpeechBuffer() + dropSpeechSession() consumePendingResponse() setMuted(false) setStatus('idle') @@ -325,8 +503,9 @@ export function useVoiceConversation({ return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) }, [enabled, stopTurn]) - // Drive the loop: after a voice-submitted turn, speak stable chunks as the - // assistant stream grows. Otherwise start listening when idle between turns. + // Drive the loop: when a voice-submitted reply appears, open a live speech + // session (which feeds itself from then on). Otherwise start listening when + // idle between turns. useEffect(() => { if (!enabled || muted) { return @@ -336,38 +515,15 @@ export function useVoiceConversation({ const response = pendingResponse() if (response) { - if (response.id !== responseIdRef.current) { - resetSpeechBuffer() - responseIdRef.current = response.id - } + openLiveSpeech(response.id) - if (response.text.length > spokenSourceLengthRef.current) { - appendSpeechText(response.text.slice(spokenSourceLengthRef.current)) - spokenSourceLengthRef.current = response.text.length - } - - const chunk = takeSpeechChunk(!response.pending && !busy) - - if (chunk) { - void speak(chunk) - - return - } - - if (!response.pending && !busy) { - awaitingSpokenResponseRef.current = false - consumePendingResponse() - resetSpeechBuffer() - pendingStartRef.current = true - setStatus('idle') - - return - } + return } if (!busy && status === 'thinking') { + // Turn finished without any speakable reply (tool-only, error). awaitingSpokenResponseRef.current = false - resetSpeechBuffer() + dropSpeechSession() pendingStartRef.current = true setStatus('idle') @@ -382,7 +538,7 @@ export function useVoiceConversation({ if (pendingStartRef.current) { void startListening() } - }, [busy, consumePendingResponse, enabled, muted, pendingResponse, speak, startListening, status]) + }, [busy, enabled, muted, openLiveSpeech, pendingResponse, startListening, status]) useEffect(() => { if (enabled && !wasEnabledRef.current) { diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index 709652dce7bf..9493227b9da9 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -6,6 +6,7 @@ import { type ChatMessage, textPart } from '@/lib/chat-messages' import { optimisticAttachmentRef } from '@/lib/chat-runtime' import { sanitizeComposerInput } from '@/lib/composer-input-sanitize' import { setMutableRef } from '@/lib/mutable-ref' +import { isVoicePlaybackActive, stopVoicePlayback } from '@/lib/voice-playback' import { $composerAttachments, clearComposerAttachments, @@ -141,6 +142,11 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { return false } + // Typing barge-in: a new send silences any in-flight spoken reply. + if (isVoicePlaybackActive()) { + stopVoicePlayback() + } + // Queue drains carry their source session explicitly. A background drain // must never inherit the currently selected session after the user moves // to another chat. diff --git a/apps/desktop/src/lib/voice-barge-in.ts b/apps/desktop/src/lib/voice-barge-in.ts new file mode 100644 index 000000000000..c9170e7d9865 --- /dev/null +++ b/apps/desktop/src/lib/voice-barge-in.ts @@ -0,0 +1,238 @@ +// VAD barge-in: watch the mic while TTS plays, fire the moment the user talks +// over it, and CAPTURE what they say. Detection alone loses the first words — +// by the time sustained speech trips the trigger and a fresh recorder spins +// up, "stop, actually—" has become "actually—". So a MediaRecorder runs on +// the monitor's stream the whole time (pre-roll), and once tripped it keeps +// rolling until the user goes quiet, delivering the complete utterance. +// +// Echo cancellation strips the app's own speaker output from the capture, the +// noise floor is calibrated while playback is already audible, and the +// sustained window filters coughs/thumps — mirrors +// tools/voice_mode.listen_for_speech on the Python surfaces. + +const CALIBRATION_MS = 400 +const SUSTAINED_MS = 300 +const MIN_TRIGGER_LEVEL = 0.075 // matches the voice loop's silenceLevel +const PRE_ROLL_RESTART_MS = 5_000 // cap pre-roll: restart the recorder while quiet +const UTTERANCE_SILENCE_MS = 1_250 // matches the voice loop's silenceMs +const UTTERANCE_MAX_MS = 30_000 + +export interface BargeMonitorCallbacks { + /** Sustained speech detected — cut playback now. */ + onSpeech: () => void + /** + * The interrupting utterance, complete from its first syllable (pre-roll + * included), delivered once the user goes quiet. `null` when capture was + * unavailable — fall back to normal listening. + */ + onUtterance?: (audio: Blob | null) => void +} + +export function monitorSpeechDuringPlayback(callbacks: BargeMonitorCallbacks): () => void { + let disposed = false + let stream: MediaStream | null = null + let context: AudioContext | null = null + let frame: number | null = null + let recorder: MediaRecorder | null = null + let chunks: Blob[] = [] + let mimeType = '' + + const cleanup = () => { + disposed = true + + if (frame !== null) { + window.cancelAnimationFrame(frame) + frame = null + } + + if (recorder && recorder.state !== 'inactive') { + recorder.ondataavailable = null + recorder.onstop = null + + try { + recorder.stop() + } catch { + // already stopped + } + } + + recorder = null + chunks = [] + void context?.close().catch(() => undefined) + context = null + stream?.getTracks().forEach(track => track.stop()) + stream = null + } + + const startSegment = () => { + if (!stream || typeof MediaRecorder === 'undefined') { + return + } + + mimeType = + ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus'].find(type => + MediaRecorder.isTypeSupported(type) + ) ?? '' + + try { + recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined) + } catch { + recorder = null + + return + } + + chunks = [] + + recorder.ondataavailable = event => { + if (event.data.size > 0) { + chunks.push(event.data) + } + } + + recorder.start(250) + } + + /** Restart the recorder to drop stale pre-roll — only valid while quiet. */ + const rotateSegment = () => { + if (!recorder || recorder.state === 'inactive') { + return + } + + recorder.ondataavailable = null + recorder.onstop = null + + try { + recorder.stop() + } catch { + // already stopped + } + + startSegment() + } + + const finishCapture = () => { + const active = recorder + const type = active?.mimeType || mimeType || 'audio/webm' + + if (!active || active.state === 'inactive') { + cleanup() + callbacks.onUtterance?.(chunks.length ? new Blob(chunks, { type }) : null) + + return + } + + active.onstop = () => { + const audio = chunks.length ? new Blob(chunks, { type }) : null + + cleanup() + callbacks.onUtterance?.(audio) + } + + active.stop() + } + void (async () => { + try { + stream = await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: true, noiseSuppression: true } + }) + + if (disposed) { + cleanup() + + return + } + + startSegment() + + context = new AudioContext() + const analyser = context.createAnalyser() + analyser.fftSize = 256 + context.createMediaStreamSource(stream).connect(analyser) + + const data = new Uint8Array(analyser.fftSize) + const startedAt = Date.now() + const floorSamples: number[] = [] + let segmentStartedAt = Date.now() + let speechStartedAt: number | null = null + let tripped = false + let trippedAt = 0 + let quietSince: number | null = null + + const tick = () => { + if (disposed) { + return + } + + analyser.getByteTimeDomainData(data) + + let sum = 0 + + for (const value of data) { + const centered = value - 128 + sum += centered * centered + } + + const level = Math.min(1, Math.sqrt(sum / data.length) / 42) + const now = Date.now() + + if (!tripped && now - startedAt < CALIBRATION_MS) { + floorSamples.push(level) + } else if (!tripped) { + const floor = floorSamples.length ? [...floorSamples].sort((a, b) => a - b)[floorSamples.length >> 1] : 0 + const trigger = Math.max(MIN_TRIGGER_LEVEL, floor * 3.5) + + if (level >= trigger) { + speechStartedAt ??= now + + if (now - speechStartedAt >= SUSTAINED_MS) { + tripped = true + trippedAt = now + quietSince = null + callbacks.onSpeech() + + if (!callbacks.onUtterance || !recorder) { + cleanup() + callbacks.onUtterance?.(null) + + return + } + } + } else { + speechStartedAt = null + + // Bound the pre-roll while quiet so the utterance blob doesn't + // accumulate the whole playback (rotating mid-speech would lose + // the onset — the whole point). + if (now - segmentStartedAt >= PRE_ROLL_RESTART_MS) { + rotateSegment() + segmentStartedAt = now + } + } + } else { + // Tripped: keep recording until the user goes quiet (endpoint). + // Playback is already stopped, so plain silence-vs-speech works. + if (level >= MIN_TRIGGER_LEVEL) { + quietSince = null + } else { + quietSince ??= now + } + + if ((quietSince && now - quietSince >= UTTERANCE_SILENCE_MS) || now - trippedAt >= UTTERANCE_MAX_MS) { + finishCapture() + + return + } + } + + frame = window.requestAnimationFrame(tick) + } + + tick() + } catch { + cleanup() + } + })() + + return cleanup +} diff --git a/apps/desktop/src/lib/voice-playback.ts b/apps/desktop/src/lib/voice-playback.ts index eea1b5b6e0ab..142423d5d5bd 100644 --- a/apps/desktop/src/lib/voice-playback.ts +++ b/apps/desktop/src/lib/voice-playback.ts @@ -1,3 +1,5 @@ +import { resolveGatewayWsUrl } from '@hermes/shared' + import { speakText } from '@/hermes' import { $voicePlayback, @@ -58,6 +60,321 @@ export function stopVoicePlayback() { }) } +// --------------------------------------------------------------------------- +// Streaming path — /api/audio/speak-stream WebSocket, raw int16 PCM frames +// scheduled through Web Audio. Speech starts on the provider's first chunk +// instead of after full synthesis + base64 transfer. +// --------------------------------------------------------------------------- + +async function resolveSpeakStreamUrl(): Promise { + const desktop = window.hermesDesktop + + if (!desktop?.getConnection) { + return null + } + + try { + // Mint a fresh credential (single-use ticket in OAuth mode), then swap the + // gateway endpoint for the PCM one — auth is shared across WS routes. + const wsUrl = await resolveGatewayWsUrl(desktop, await desktop.getConnection()) + const url = new URL(wsUrl) + + if (!url.pathname.endsWith('/api/ws')) { + return null + } + + url.pathname = url.pathname.replace(/\/api\/ws$/, '/api/audio/speak-stream') + + return url.toString() + } catch { + return null + } +} + +export interface SpeechStreamSession { + /** Feed more reply text as it streams in. Safe after `finish` (no-op). */ + append: (text: string) => void + /** No more text coming — resolves `done` once the audio drains. */ + finish: () => void + /** + * 'done' — audio fully played (or barged via stopVoicePlayback) + * 'fallback'— no audio ever produced; caller should speak the accumulated + * text through `playSpeechText` instead. + */ + done: Promise<'done' | 'fallback'> +} + +/** + * Open a live speech session: one WebSocket + one AudioContext for a whole + * reply. Text is appended as LLM deltas arrive; the server cuts sentences and + * streams PCM back while generation continues, so speech overlaps the text + * stream (ChatGPT-style) with no per-sentence connection or synthesis gaps. + */ +function openSpeechStream(wsUrl: string, options: VoicePlaybackOptions): SpeechStreamSession { + const ws = new WebSocket(wsUrl) + ws.binaryType = 'arraybuffer' + + let context: AudioContext | null = null + let streamRate = 24_000 + let nextStartAt = 0 + let carry: null | Uint8Array = null + let started = false + let settled = false + let finished = false + const pendingSends: string[] = [] + + let settle: (value: 'done' | 'fallback') => void = () => undefined + + const done = new Promise<'done' | 'fallback'>(resolve => { + settle = value => { + if (settled) { + return + } + + settled = true + currentStop = null + + try { + ws.close() + } catch { + // already closed + } + + void context?.close().catch(() => undefined) + context = null + resolve(value) + } + }) + + const send = (frame: object) => { + const data = JSON.stringify(frame) + + if (ws.readyState === WebSocket.OPEN) { + ws.send(data) + } else if (ws.readyState === WebSocket.CONNECTING) { + pendingSends.push(data) + } + } + + // stopVoicePlayback() → immediate barge-in: kill the socket (the server + // aborts synthesis on disconnect) and the audio context (cuts sound now). + currentStop = () => settle('done') + + const finishWhenDrained = () => { + const remainingMs = context ? Math.max(0, nextStartAt - context.currentTime) * 1_000 : 0 + window.setTimeout(() => settle('done'), remainingMs + 100) + } + + const schedule = (data: ArrayBuffer) => { + if (!context) { + return + } + + // Provider chunks are not sample-aligned — carry any odd byte over. + let bytes = new Uint8Array(data) + + if (carry) { + const joined = new Uint8Array(carry.length + bytes.length) + joined.set(carry) + joined.set(bytes, carry.length) + bytes = joined + carry = null + } + + const usable = bytes.length - (bytes.length % 2) + + if (bytes.length !== usable) { + carry = bytes.slice(usable) + } + + if (!usable) { + return + } + + const pcm = new Int16Array(bytes.buffer, bytes.byteOffset, usable / 2) + const buffer = context.createBuffer(1, pcm.length, streamRate) + const channel = buffer.getChannelData(0) + + for (let index = 0; index < pcm.length; index += 1) { + channel[index] = pcm[index] / 32_768 + } + + const source = context.createBufferSource() + source.buffer = buffer + source.connect(context.destination) + + const startAt = Math.max(context.currentTime + 0.05, nextStartAt) + source.start(startAt) + nextStartAt = startAt + buffer.duration + + if (!started) { + started = true + setVoicePlaybackState(currentState('speaking', options)) + } + } + + ws.onopen = () => { + pendingSends.splice(0).forEach(data => ws.send(data)) + } + + ws.onmessage = event => { + if (typeof event.data !== 'string') { + schedule(event.data as ArrayBuffer) + + return + } + + let frame: { channels?: number; sample_rate?: number; type?: string } + + try { + frame = JSON.parse(event.data) as typeof frame + } catch { + return + } + + if (frame.type === 'start') { + streamRate = frame.sample_rate || 24_000 + context = new AudioContext() + nextStartAt = 0 + } else if (frame.type === 'end') { + finishWhenDrained() + } else if (frame.type === 'fallback') { + settle(started ? 'done' : 'fallback') + } + } + + // A drop before any audio means the endpoint is unavailable (old backend, + // auth, network) → fall back. After audio started, replaying the whole + // message via POST would stutter — treat what played as the playback. + ws.onerror = () => settle(started ? 'done' : 'fallback') + ws.onclose = () => (started ? finishWhenDrained() : settle('fallback')) + + return { + // Raw deltas — the server strips markdown/emoji per *sentence*, which is + // the only safe granularity when constructs span delta boundaries. + append: text => { + if (text && !finished && !settled) { + send({ text }) + } + }, + finish: () => { + if (!finished && !settled) { + finished = true + send({ done: true }) + } + }, + done + } +} + +/** + * Live-speak an in-progress reply: open a session, then `append` deltas and + * `finish` when generation completes. Resolves null when streaming is + * unavailable (old backend / non-chunked provider) — the caller falls back to + * whole-text `playSpeechText`. + */ +export async function startSpeechStream(options: VoicePlaybackOptions): Promise { + const wsUrl = await resolveSpeakStreamUrl() + + if (!wsUrl) { + return null + } + + stopVoicePlayback() + setVoicePlaybackState(currentState('preparing', options)) + + const session = openSpeechStream(wsUrl, options) + + void session.done.then(outcome => { + if (outcome === 'done') { + setVoicePlaybackState(currentState('idle')) + } + }) + + return session +} + +/** One-shot playback of complete text over the streaming WS. */ +function playSpeechStream(wsUrl: string, text: string, options: VoicePlaybackOptions): Promise<'fallback' | 'played'> { + const session = openSpeechStream(wsUrl, options) + session.append(text) + session.finish() + + return session.done.then(outcome => (outcome === 'done' ? 'played' : 'fallback')) +} + +async function playSpeechDataUrl( + speakableText: string, + options: VoicePlaybackOptions, + isCurrent: () => boolean +): Promise { + const response = await speakText(speakableText) + + if (!isCurrent()) { + return false + } + + const audio = new Audio(response.data_url) + currentAudio = audio + setVoicePlaybackState(currentState('speaking', options, audio)) + + await new Promise((resolve, reject) => { + let stall: number | null = null + + const cleanup = () => { + if (stall !== null) { + window.clearTimeout(stall) + stall = null + } + + audio.removeEventListener('ended', onEnded) + audio.removeEventListener('error', onError) + audio.removeEventListener('timeupdate', armStall) + currentStop = null + } + + const armStall = () => { + if (stall !== null) { + window.clearTimeout(stall) + } + + stall = window.setTimeout(() => { + cleanup() + reject(new Error('Playback stalled')) + }, PLAYBACK_STALL_MS) + } + + const onEnded = () => { + cleanup() + resolve() + } + + const onError = () => { + cleanup() + reject(new Error('Playback failed')) + } + + currentStop = () => { + cleanup() + resolve() + } + + audio.addEventListener('ended', onEnded, { once: true }) + audio.addEventListener('error', onError, { once: true }) + audio.addEventListener('timeupdate', armStall) + armStall() + void audio.play().catch(onError) + }) + + if (!isCurrent()) { + return false + } + + currentAudio = null + + return true +} + export async function playSpeechText(text: string, options: VoicePlaybackOptions): Promise { stopVoicePlayback() @@ -73,72 +390,35 @@ export async function playSpeechText(text: string, options: VoicePlaybackOptions setVoicePlaybackState(currentState('preparing', options)) try { - const response = await speakText(speakableText) + // Streaming first; the POST data-URL path is the fallback for backends + // without the WS endpoint or providers without a chunked API. + const streamUrl = await resolveSpeakStreamUrl() + + if (streamUrl && isCurrent()) { + const outcome = await playSpeechStream(streamUrl, speakableText, options) + + if (outcome === 'played') { + if (!isCurrent()) { + return false + } + + setVoicePlaybackState(currentState('idle')) + + return true + } + } if (!isCurrent()) { return false } - const audio = new Audio(response.data_url) - currentAudio = audio - setVoicePlaybackState(currentState('speaking', options, audio)) + const played = await playSpeechDataUrl(speakableText, options, isCurrent) - await new Promise((resolve, reject) => { - let stall: number | null = null - - const cleanup = () => { - if (stall !== null) { - window.clearTimeout(stall) - stall = null - } - - audio.removeEventListener('ended', onEnded) - audio.removeEventListener('error', onError) - audio.removeEventListener('timeupdate', armStall) - currentStop = null - } - - const armStall = () => { - if (stall !== null) { - window.clearTimeout(stall) - } - - stall = window.setTimeout(() => { - cleanup() - reject(new Error('Playback stalled')) - }, PLAYBACK_STALL_MS) - } - - const onEnded = () => { - cleanup() - resolve() - } - - const onError = () => { - cleanup() - reject(new Error('Playback failed')) - } - - currentStop = () => { - cleanup() - resolve() - } - - audio.addEventListener('ended', onEnded, { once: true }) - audio.addEventListener('error', onError, { once: true }) - audio.addEventListener('timeupdate', armStall) - armStall() - void audio.play().catch(onError) - }) - - if (!isCurrent()) { - return false + if (played) { + setVoicePlaybackState(currentState('idle')) } - currentAudio = null - setVoicePlaybackState(currentState('idle')) - - return true + return played } catch (error) { if (isCurrent()) { currentStop = null diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index f6c43a550b8b..1181631cb2e1 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -9,6 +9,7 @@ Usage: python -m hermes_cli.main web --port 8080 """ +import contextlib from contextlib import asynccontextmanager, contextmanager import asyncio @@ -28,6 +29,7 @@ import json import logging import mimetypes import os +import queue import re import secrets import shlex @@ -4269,10 +4271,14 @@ async def transcribe_audio_upload(payload: AudioTranscriptionRequest): tmp.write(audio_bytes) temp_path = tmp.name - from tools.transcription_tools import transcribe_audio + # transcribe_recording (not raw transcribe_audio): filters Whisper + # hallucinations and maps provider "empty transcript" errors to a + # successful empty result — the live voice loop treats "" as silence + # and re-listens instead of surfacing a 400 on every quiet turn. + from tools.voice_mode import transcribe_recording loop = asyncio.get_running_loop() - result = await loop.run_in_executor(None, transcribe_audio, temp_path) + result = await loop.run_in_executor(None, transcribe_recording, temp_path) except HTTPException: raise except Exception as exc: @@ -4464,6 +4470,151 @@ async def speak_text(payload: TTSSpeakRequest): } +def _split_text_for_speak_stream(text: str, cap: int) -> list: + """Split *text* into provider-cap-sized pieces on sentence boundaries.""" + from tools.tts_streaming import SENTENCE_BOUNDARY_RE as _SENTENCE_BOUNDARY_RE + + cap = cap if cap and cap > 0 else 4000 + pieces, buf = [], "" + for sentence in filter(str.strip, _SENTENCE_BOUNDARY_RE.split(text)): + while len(sentence) > cap: + pieces.append(sentence[:cap]) + sentence = sentence[cap:] + if buf and len(buf) + len(sentence) + 1 > cap: + pieces.append(buf) + buf = sentence + else: + buf = f"{buf} {sentence}" if buf else sentence + if buf: + pieces.append(buf) + return pieces + + +@app.websocket("/api/audio/speak-stream") +async def speak_stream_ws(ws: "WebSocket") -> None: + """Streaming TTS for the desktop: text in, raw int16 PCM frames out. + + The socket is a per-reply speech *session*: the client feeds text + incrementally as LLM deltas arrive, the server cuts sentences + (``SentenceChunker`` — same cutter as the CLI/TUI speaker pipeline) and + streams each one's PCM the moment it's ready. Speech overlaps generation, + exactly like the token→sentence→TTS pipelining the realtime-voice + literature converges on. + + Protocol: + client → ``{"text": "..."}`` frames (incremental; may combine with done), + ``{"done": true}`` when the reply is complete, + ``{"stop": true}`` or disconnect = barge-in + server → ``{"type": "start", "sample_rate": N, "channels": 1}``, + binary PCM frames, then ``{"type": "end"}`` + server → ``{"type": "fallback"}`` when the configured provider has no + chunked API — the client uses the POST endpoint instead. + """ + if not _ws_auth_ok(ws): + await ws.close(code=4401) + return + if not _ws_request_is_allowed(ws): + await ws.close(code=4403) + return + await ws.accept() + + loop = asyncio.get_running_loop() + + def _resolve(): + from tools.tts_streaming import resolve_streaming_provider + from tools.tts_tool import _get_provider, _load_tts_config, _resolve_max_text_length + + cfg = _load_tts_config() + streamer = resolve_streaming_provider(cfg) + cap = _resolve_max_text_length(_get_provider(cfg), cfg) if streamer else 0 + return streamer, cap + + try: + streamer, cap = await loop.run_in_executor(None, _resolve) + except Exception: + _log.exception("speak-stream provider resolution failed") + streamer, cap = None, 0 + if streamer is None: + with contextlib.suppress(Exception): + await ws.send_json({"type": "fallback"}) + await ws.close() + return + + await ws.send_json( + {"type": "start", "sample_rate": streamer.sample_rate, "channels": streamer.channels} + ) + + stop = threading.Event() + text_q: queue.Queue = queue.Queue() # str deltas; None = end-of-text + chunks: asyncio.Queue = asyncio.Queue() # PCM out; None = synthesis done + + def _produce(): + from tools.tts_streaming import SentenceChunker + from tools.tts_tool import _strip_markdown_for_tts + + chunker = SentenceChunker() + + def _sentences(): + while not stop.is_set(): + delta = text_q.get() + if delta is None: + yield from chunker.flush() + return + yield from chunker.feed(delta) + + try: + for sentence in _sentences(): + cleaned = _strip_markdown_for_tts(sentence) + if not cleaned: + continue + for piece in _split_text_for_speak_stream(cleaned, cap): + for chunk in streamer.stream(piece): + if stop.is_set(): + return + loop.call_soon_threadsafe(chunks.put_nowait, chunk) + except Exception as exc: + _log.warning("speak-stream synthesis failed: %s", exc) + finally: + loop.call_soon_threadsafe(chunks.put_nowait, None) + + threading.Thread(target=_produce, daemon=True).start() + + async def _pump_client(): + # Text frames feed synthesis; done ends the text; stop/disconnect + # (or any unparseable frame) is barge-in. + try: + while True: + frame = json.loads(await ws.receive_text()) + if frame.get("text"): + text_q.put(str(frame["text"])) + if frame.get("stop"): + break + if frame.get("done"): + text_q.put(None) + except Exception: + pass + stop.set() + text_q.put(None) # unblock the producer + + pump = asyncio.ensure_future(_pump_client()) + try: + while True: + chunk = await chunks.get() + if chunk is None: + break + await ws.send_bytes(chunk) + if not stop.is_set(): + await ws.send_json({"type": "end"}) + except (WebSocketDisconnect, RuntimeError): + pass + finally: + stop.set() + text_q.put(None) + pump.cancel() + with contextlib.suppress(Exception): + await ws.close() + + @app.get("/api/actions/{name}/status") async def get_action_status(name: str, lines: int = 200): """Tail an action log and report whether the process is still running.""" diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index d3e684bb3c2f..ea19195c2826 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2192,7 +2192,7 @@ class TestWebServerEndpoints: captured = {} - def fake_transcribe_audio(path): + def fake_transcribe_audio(path, model=None): captured["path"] = path return { "success": True, @@ -2219,6 +2219,36 @@ class TestWebServerEndpoints: assert captured["path"].endswith(".webm") assert not Path(captured["path"]).exists() + def test_audio_transcription_no_speech_is_not_an_error(self, monkeypatch): + """A provider hearing silence (empty transcript) must return 200/"" — + the live voice loop treats it as a quiet turn and re-listens, instead + of surfacing a 400 toast on every pause (the ElevenLabs empty- + transcript spam).""" + import tools.transcription_tools as transcription_tools + + monkeypatch.setattr( + transcription_tools, + "transcribe_audio", + lambda path, model=None: { + "success": False, + "transcript": "", + "error": "ElevenLabs STT returned empty transcript", + "no_speech": True, + }, + ) + + resp = self.client.post( + "/api/audio/transcribe", + json={ + "data_url": "data:audio/webm;base64,aGVsbG8=", + "mime_type": "audio/webm", + }, + ) + + assert resp.status_code == 200 + assert resp.json()["ok"] is True + assert resp.json()["transcript"] == "" + def test_audio_transcription_rejects_invalid_base64(self): resp = self.client.post( "/api/audio/transcribe", diff --git a/tests/hermes_cli/test_web_server_speak_stream.py b/tests/hermes_cli/test_web_server_speak_stream.py new file mode 100644 index 000000000000..50a36f35a90f --- /dev/null +++ b/tests/hermes_cli/test_web_server_speak_stream.py @@ -0,0 +1,163 @@ +"""/api/audio/speak-stream — desktop streaming TTS over WebSocket.""" + +from __future__ import annotations + +import json +from urllib.parse import urlencode + +import pytest +from starlette.testclient import TestClient +from starlette.websockets import WebSocketDisconnect + +from hermes_cli import web_server + + +@pytest.fixture +def stream_client(monkeypatch, _isolate_hermes_home): + previous_auth_required = getattr(web_server.app.state, "auth_required", None) + web_server.app.state.auth_required = False + + client = TestClient(web_server.app) + try: + yield client + finally: + close = getattr(client, "close", None) + if close is not None: + close() + if previous_auth_required is None: + if hasattr(web_server.app.state, "auth_required"): + delattr(web_server.app.state, "auth_required") + else: + web_server.app.state.auth_required = previous_auth_required + + +def _url(token: str | None = None) -> str: + return f"/api/audio/speak-stream?{urlencode({'token': token or web_server._SESSION_TOKEN})}" + + +class _FakeStreamer: + sample_rate = 24000 + channels = 1 + + def __init__(self, chunks): + self.chunks = chunks + self.requests: list[str] = [] + + def stream(self, text): + self.requests.append(text) + yield from self.chunks + + +def _patch_provider(monkeypatch, streamer, cap=4000): + monkeypatch.setattr("tools.tts_streaming.resolve_streaming_provider", lambda cfg: streamer) + monkeypatch.setattr("tools.tts_tool._load_tts_config", lambda: {}) + monkeypatch.setattr("tools.tts_tool._get_provider", lambda cfg: "fake") + monkeypatch.setattr("tools.tts_tool._resolve_max_text_length", lambda provider, cfg: cap) + + +def test_rejects_bad_token(stream_client): + with pytest.raises(WebSocketDisconnect) as exc: + with stream_client.websocket_connect(_url(token="wrong")): + pass + assert exc.value.code == 4401 + + +def test_fallback_frame_when_no_streaming_provider(stream_client, monkeypatch): + _patch_provider(monkeypatch, None) + with stream_client.websocket_connect(_url()) as conn: + assert conn.receive_json() == {"type": "fallback"} + + +def test_streams_pcm_frames_then_end(stream_client, monkeypatch): + streamer = _FakeStreamer([b"\x01\x02\x03\x04", b"\x05\x06"]) + _patch_provider(monkeypatch, streamer) + + with stream_client.websocket_connect(_url()) as conn: + start = conn.receive_json() + assert start == {"type": "start", "sample_rate": 24000, "channels": 1} + + conn.send_text(json.dumps({"text": "Hello there.", "done": True})) + assert conn.receive_bytes() == b"\x01\x02\x03\x04" + assert conn.receive_bytes() == b"\x05\x06" + assert conn.receive_json() == {"type": "end"} + + assert streamer.requests == ["Hello there."] + + +def test_incremental_deltas_are_cut_into_sentences(stream_client, monkeypatch): + """Text fed across frames is chunked and synthesized while more arrives.""" + streamer = _FakeStreamer([b"\x00\x00"]) + _patch_provider(monkeypatch, streamer) + + with stream_client.websocket_connect(_url()) as conn: + assert conn.receive_json()["type"] == "start" + conn.send_text(json.dumps({"text": "This is the first full"})) + conn.send_text(json.dumps({"text": " sentence of the reply. And"})) + # The first sentence is complete — PCM must arrive before `done`. + assert conn.receive_bytes() == b"\x00\x00" + conn.send_text(json.dumps({"text": " here is the second one.", "done": True})) + assert conn.receive_bytes() == b"\x00\x00" + assert conn.receive_json() == {"type": "end"} + + assert streamer.requests == [ + "This is the first full sentence of the reply.", + "And here is the second one.", + ] + + +def test_stop_frame_cuts_synthesis(stream_client, monkeypatch): + streamer = _FakeStreamer([b"\x00\x00"]) + _patch_provider(monkeypatch, streamer) + + with stream_client.websocket_connect(_url()) as conn: + assert conn.receive_json()["type"] == "start" + conn.send_text(json.dumps({"stop": True})) + # Socket closes without an "end" frame — barge-in, not completion. + with pytest.raises(WebSocketDisconnect): + conn.receive_text() + assert streamer.requests == [] + + +def test_long_text_is_split_across_provider_requests(stream_client, monkeypatch): + streamer = _FakeStreamer([b"\x00\x00"]) + _patch_provider(monkeypatch, streamer, cap=24) + + with stream_client.websocket_connect(_url()) as conn: + assert conn.receive_json()["type"] == "start" + conn.send_text( + json.dumps( + {"text": "First sentence here. Second sentence here. Third one.", "done": True} + ) + ) + # One PCM frame per split piece, then end. + frames = 0 + while True: + message = conn.receive() + if message.get("bytes") is not None: + frames += 1 + else: + assert json.loads(message["text"]) == {"type": "end"} + break + + assert len(streamer.requests) > 1 + assert frames == len(streamer.requests) + # Nothing lost in the split: every sentence reached the provider. + joined = " ".join(streamer.requests) + for fragment in ("First sentence here.", "Second sentence here.", "Third one."): + assert fragment in joined + + +def test_split_text_respects_cap_and_preserves_content(): + text = "Alpha beta. Gamma delta epsilon. Zeta eta theta iota kappa." + pieces = web_server._split_text_for_speak_stream(text, 30) + assert pieces + assert all(len(piece) <= 30 for piece in pieces) + joined = " ".join(pieces) + for word in text.replace(".", "").split(): + assert word in joined + + +def test_split_text_hard_splits_oversized_sentence(): + pieces = web_server._split_text_for_speak_stream("x" * 100, 30) + assert all(len(piece) <= 30 for piece in pieces) + assert sum(len(piece) for piece in pieces) == 100 From 83456d0bc6e6fb433b16b8b980e1b54df5d8063b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 17:47:25 -0500 Subject: [PATCH 053/238] docs(voice): streaming TTS + barge-in across surfaces --- website/docs/user-guide/features/voice-mode.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/website/docs/user-guide/features/voice-mode.md b/website/docs/user-guide/features/voice-mode.md index 14a4235e3e7c..d0945478dac6 100644 --- a/website/docs/user-guide/features/voice-mode.md +++ b/website/docs/user-guide/features/voice-mode.md @@ -159,11 +159,20 @@ Both `silence_threshold` and `silence_duration` are configurable in `config.yaml ### Streaming TTS -When TTS is enabled, the agent speaks its reply **sentence-by-sentence** as it generates text — you don't wait for the full response: +When TTS is enabled, the agent speaks its reply **sentence-by-sentence** as it generates text — you don't wait for the full response. This works with **every TTS provider**: 1. Buffers text deltas into complete sentences (min 20 chars) -2. Strips markdown formatting and `` blocks -3. Generates and plays audio per sentence in real-time +2. Strips markdown formatting, emoji, and `` blocks +3. Plays audio per sentence in real-time — providers with a chunked PCM API (ElevenLabs, OpenAI) stream raw audio for the lowest time-to-first-word; every other provider (including the default Edge) synthesizes and plays each sentence as it completes + +The same pipeline runs in the classic CLI, the TUI, and the desktop app. In a desktop voice conversation the reply text is fed **live** into a per-reply speech WebSocket as the model generates it, so speech overlaps generation — one socket and one audio clock per reply, no per-sentence connection gaps. + +### Barge-in + +You can interrupt the agent mid-speech: + +- **Talk over it** — in continuous voice mode, a voice-activity monitor listens while the agent speaks and cuts playback the moment you start talking, then goes straight back to recording. The detector calibrates its noise floor against the playback itself, so speaker bleed doesn't self-trigger. Disable with `voice.barge_in: false` in `config.yaml`. +- **Type or press the record key** — sending a new message or hitting the push-to-talk key stops playback instantly on every surface. ### Hallucination Filter From 393c100a929ba77fee2b49b0db6d842f1ccb1c53 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 17:53:06 -0500 Subject: [PATCH 054/238] feat(voice): speech-interrupted latch in the TTS streaming core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mark_speech_interrupted() / take_speech_interrupted(): a one-shot, TTL'd (120s) latch plus SPEECH_INTERRUPTED_NOTE. Barge-in paths mark it when they cut live speech; the next turn's submit path pops it and prepends the note to the model-bound message — API-call local, never persisted, so history and prompt caching are untouched. --- tests/tools/test_tts_streaming.py | 20 ++++++++++++++++++++ tools/tts_streaming.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/tests/tools/test_tts_streaming.py b/tests/tools/test_tts_streaming.py index 77241ea485b7..dd5b921d1695 100644 --- a/tests/tools/test_tts_streaming.py +++ b/tests/tools/test_tts_streaming.py @@ -53,6 +53,26 @@ class TestSentenceChunker: ] +# ── Interruption latch ─────────────────────────────────────────────────── + + +class TestSpeechInterruptedLatch: + def test_take_pops_and_reports_recent_barge(self): + ts.mark_speech_interrupted() + assert ts.take_speech_interrupted() is True + assert ts.take_speech_interrupted() is False # one-shot + + def test_untouched_latch_is_false(self): + ts._interrupted_at = None + assert ts.take_speech_interrupted() is False + + def test_stale_barge_expires(self, monkeypatch): + ts.mark_speech_interrupted() + at = ts._interrupted_at + monkeypatch.setattr(ts.time, "monotonic", lambda: at + ts._INTERRUPT_TTL_S + 1) + assert ts.take_speech_interrupted() is False + + # ── Registry + resolver ────────────────────────────────────────────────── diff --git a/tools/tts_streaming.py b/tools/tts_streaming.py index 8d773d954f59..746886ccce3f 100644 --- a/tools/tts_streaming.py +++ b/tools/tts_streaming.py @@ -23,6 +23,7 @@ from __future__ import annotations import logging import re +import time from abc import ABC, abstractmethod from typing import Callable, Dict, Iterator, List, Optional @@ -30,6 +31,34 @@ from tools.tts_tool import _get_provider, get_env_value logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Interruption latch — lets the model know it was cut off mid-speech +# --------------------------------------------------------------------------- +# When the user barges in on a spoken reply (talks over it, types, hits the +# record key), the surface marks the latch; the next turn's submit path takes +# it and prepends SPEECH_INTERRUPTED_NOTE to the model-bound message (API-call +# local — never persisted, same as the CLI's model-switch notes). The TTL +# keeps a stale barge from annotating an unrelated message minutes later. + +SPEECH_INTERRUPTED_NOTE = ( + "[Note: the user interrupted your previous spoken reply before it finished.]" +) +_INTERRUPT_TTL_S = 120.0 +_interrupted_at: Optional[float] = None + + +def mark_speech_interrupted() -> None: + global _interrupted_at + _interrupted_at = time.monotonic() + + +def take_speech_interrupted() -> bool: + """Pop the latch; True when a barge happened within the TTL.""" + global _interrupted_at + at, _interrupted_at = _interrupted_at, None + return at is not None and time.monotonic() - at < _INTERRUPT_TTL_S + # Sentence boundary: after .!? followed by whitespace, or a blank line. SENTENCE_BOUNDARY_RE = re.compile(r"(?<=[.!?])(?:\s|\n)|(?:\n\n)") _THINK_BLOCK_RE = re.compile(r"].*?", flags=re.DOTALL) From 05b3637d8bc6ef23cf3ecbc5ee2f3e14b32a8180 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 17:53:06 -0500 Subject: [PATCH 055/238] feat(voice): CLI + TUI tell the model when its spoken reply was cut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI: the VAD monitor's playback cut and the record-key interrupt both mark the latch; the chat path prepends the note via the existing _prepend_note_to_message channel. TUI: _tts_stream_stop() grows a user_barge flag (False for /voice off — a mode change isn't an interruption; no-op when speech already finished), the VAD monitor marks on cut, prompt.submit accepts interrupted:true from clients, and _run_prompt_submit pops the latch into the run message. --- cli.py | 9 +++++++ tests/test_tui_gateway_server.py | 42 +++++++++++++++++++++++++++++++- tui_gateway/server.py | 39 ++++++++++++++++++++++++++--- 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/cli.py b/cli.py index 18e167c00971..870fd468b3aa 100644 --- a/cli.py +++ b/cli.py @@ -11296,6 +11296,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): def _cut_playback(): if not self._voice_tts_done.is_set(): + from tools.tts_streaming import mark_speech_interrupted + mark_speech_interrupted() self._voice_barge_capture.set() stop_event.set() stop_playback() @@ -12302,6 +12304,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): if _srn: agent_message = _prepend_note_to_message(agent_message, _srn) self._pending_skills_reload_note = None + # Barged mid-speech (VAD or record key)? Tell the model it was + # cut off — same one-shot, API-local note channel as above. + from tools.tts_streaming import SPEECH_INTERRUPTED_NOTE, take_speech_interrupted + if take_speech_interrupted(): + agent_message = _prepend_note_to_message(agent_message, SPEECH_INTERRUPTED_NOTE) _moa_cfg = getattr(self, "_pending_moa_config", None) self._pending_moa_config = None if _moa_cfg is None: @@ -14179,6 +14186,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # the stop event drains the streaming pipeline if one is live. if not cli_ref._voice_tts_done.is_set(): try: + from tools.tts_streaming import mark_speech_interrupted + mark_speech_interrupted() if cli_ref._voice_tts_stop is not None: cli_ref._voice_tts_stop.set() from tools.voice_mode import stop_playback diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index e5169339c8c2..03d96b45966e 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -11821,11 +11821,50 @@ def test_tts_stream_begin_barges_in_on_previous_pipeline(monkeypatch): server._tts_stream_stop() +def test_tts_stream_stop_latches_interruption_for_next_turn(monkeypatch): + """Cutting live speech (interrupt / typing barge) marks the latch the next + turn's model note consumes; a mode change (user_barge=False) does not.""" + import tools.tts_streaming as ts + + ts._interrupted_at = None + monkeypatch.setenv("HERMES_VOICE_TTS", "1") + monkeypatch.setenv("HERMES_VOICE", "0") + _fake_tts_modules(monkeypatch) + + server._tts_stream_begin() + server._tts_stream_stop() # default: user barge + assert ts.take_speech_interrupted() is True + + server._tts_stream_begin() + server._tts_stream_stop(user_barge=False) # /voice off + assert ts.take_speech_interrupted() is False + + +def test_tts_stream_stop_after_natural_finish_does_not_latch(monkeypatch): + """Speech that already finished (done set) isn't an interruption.""" + import tools.tts_streaming as ts + + ts._interrupted_at = None + monkeypatch.setenv("HERMES_VOICE_TTS", "1") + monkeypatch.setenv("HERMES_VOICE", "0") + _fake_tts_modules(monkeypatch) + + server._tts_stream_begin() + with server._tts_stream_lock: + server._tts_stream_state["done"].set() + server._tts_stream_stop() + assert ts.take_speech_interrupted() is False + + def test_tts_stream_vad_barge_in_cuts_pipeline_and_submits_capture(monkeypatch, tmp_path): """User speech during playback cuts TTS at the moment of detection (voice.interrupted), then the captured interruption is transcribed and emitted as voice.transcript so the TUI submits it — complete from its - first syllable, no re-record round trip.""" + first syllable, no re-record round trip. The cut also latches the + speech-interrupted note for the next turn.""" + import tools.tts_streaming as ts + + ts._interrupted_at = None monkeypatch.setenv("HERMES_VOICE_TTS", "1") monkeypatch.setenv("HERMES_VOICE", "1") monkeypatch.setattr(server, "_load_cfg", lambda: {"voice": {"barge_in": True}}) @@ -11859,4 +11898,5 @@ def test_tts_stream_vad_barge_in_cuts_pipeline_and_submits_capture(monkeypatch, assert ("voice.interrupted", None) in events assert ("voice.transcript", {"text": "stop, actually—"}) in events assert not wav.exists() # capture temp file cleaned up + assert ts.take_speech_interrupted() is True # VAD cut latches the model note server._tts_stream_stop() diff --git a/tui_gateway/server.py b/tui_gateway/server.py index ba7372673835..4212f7d0957f 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -9745,6 +9745,12 @@ def _(rid, params: dict) -> dict: raw_text = params.get("text", "") text = sanitize_user_prompt_text(raw_text) if isinstance(raw_text, str) else raw_text truncate_user_ordinal = params.get("truncate_before_user_ordinal") + if params.get("interrupted"): + # Client-side barge-in (desktop VAD / typing over playback) — latch it + # so this turn's model message carries the interruption note. + from tools.tts_streaming import mark_speech_interrupted + + mark_speech_interrupted() session, err = _sess_nowait(params, rid) if err: return err @@ -10430,8 +10436,22 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: # Streaming TTS: voice-mode replies are spoken sentence-by-sentence # as tokens arrive (CLI parity) instead of after the full turn. + # begin() first — it cuts any still-speaking previous turn, and + # that cut IS this turn's barge-in, so it must latch before we + # consume the latch below. tts_queue = _tts_stream_begin() + # Barged mid-speech? Tell the model (API-message note, same + # enrichment channel as attached images) so it can react + # ("rude!") instead of being oblivious to its own interruption. + from tools.tts_streaming import SPEECH_INTERRUPTED_NOTE, take_speech_interrupted + + if take_speech_interrupted(): + if isinstance(run_message, str): + run_message = f"{SPEECH_INTERRUPTED_NOTE}\n\n{run_message}" + elif isinstance(run_message, list): + run_message = [{"type": "text", "text": SPEECH_INTERRUPTED_NOTE}, *run_message] + def _stream(delta): with session["history_lock"]: _append_inflight_delta(session, delta) @@ -15568,13 +15588,22 @@ def _tts_stream_begin() -> Optional[queue.Queue]: return text_queue -def _tts_stream_stop() -> None: - """Barge-in: cut any in-flight streaming TTS (new turn, interrupt, /voice off).""" +def _tts_stream_stop(user_barge: bool = True) -> None: + """Cut any in-flight streaming TTS (new turn, interrupt, /voice off). + + *user_barge* latches the interruption for the next turn's model note + (``mark_speech_interrupted``) — pass ``False`` for mode changes like + ``/voice off`` where the user isn't talking over the reply. + """ global _tts_stream_state with _tts_stream_lock: state, _tts_stream_state = _tts_stream_state, None if state is None: return + if user_barge and not state["done"].is_set(): + from tools.tts_streaming import mark_speech_interrupted + + mark_speech_interrupted() state["stop"].set() try: from tools.voice_mode import stop_playback @@ -15594,6 +15623,7 @@ def _tts_stream_barge_in_monitor(stop: threading.Event, done: threading.Event) - lost between detection and the next recording start. """ try: + from tools.tts_streaming import mark_speech_interrupted from tools.voice_mode import listen_for_speech, stop_playback, transcribe_recording barged = threading.Event() @@ -15601,6 +15631,7 @@ def _tts_stream_barge_in_monitor(stop: threading.Event, done: threading.Event) - def _cut_playback(): if not done.is_set(): barged.set() + mark_speech_interrupted() stop.set() stop_playback() _voice_emit("voice.interrupted") @@ -15716,7 +15747,7 @@ def _(rid, params: dict) -> dict: # Clear TTS so it can be toggled independently after voice is off, # and silence any in-flight streaming speech. os.environ["HERMES_VOICE_TTS"] = "0" - _tts_stream_stop() + _tts_stream_stop(user_barge=False) return _ok( rid, @@ -15734,7 +15765,7 @@ def _(rid, params: dict) -> dict: # Runtime-only flag (CLI parity) — see voice.toggle on/off above. os.environ["HERMES_VOICE_TTS"] = "1" if new_value else "0" if not new_value: - _tts_stream_stop() + _tts_stream_stop(user_barge=False) # Include ``record_key`` on every branch so a /voice tts toggle # doesn't reset the TUI's cached shortcut to the default when a # user has a custom binding configured (Copilot review, round 2 From 177a57f70cfcc65624337ea61a91a6cc6e9d63be Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 17:53:06 -0500 Subject: [PATCH 056/238] feat(voice): desktop flags interrupted submits markVoicePlaybackInterrupted() / takeVoicePlaybackInterrupted() mirror the backend latch in the renderer (the barge happens client-side, where the audio plays). VAD barges and typing over playback mark it; the next prompt.submit carries interrupted:true, which the TUI gateway latches into the model note. --- .../composer/hooks/use-voice-conversation.ts | 2 + .../hooks/use-prompt-actions/index.test.tsx | 38 +++++++++++++++++++ .../hooks/use-prompt-actions/submit.ts | 22 +++++++++-- apps/desktop/src/lib/voice-playback.ts | 21 ++++++++++ .../docs/user-guide/features/voice-mode.md | 2 + 5 files changed, 82 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts index dc8e7dcdf138..2ec43c83c0b8 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-voice-conversation.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useI18n } from '@/i18n' import { monitorSpeechDuringPlayback } from '@/lib/voice-barge-in' import { + markVoicePlaybackInterrupted, playSpeechText, type SpeechStreamSession, startSpeechStream, @@ -267,6 +268,7 @@ export function useVoiceConversation({ onSpeech: () => { bargeCapturePendingRef.current = true onBarge() + markVoicePlaybackInterrupted() stopVoicePlayback() }, onUtterance: audio => { diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index f83aa1235725..030ed8751fbf 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -536,6 +536,44 @@ describe('usePromptActions submit / queue drain semantics', () => { ) }) + it('flags prompt.submit with interrupted:true after a voice-playback barge', async () => { + const { markVoicePlaybackInterrupted } = await import('@/lib/voice-playback') + const requestGateway = vi.fn(async () => ({}) as never) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + /> + ) + + markVoicePlaybackInterrupted() + await handle!.submitText('stop! rude interruption') + + // The latch is one-shot: the flag rides this submit, the next is clean. + expect(requestGateway).toHaveBeenCalledWith( + 'prompt.submit', + { + session_id: RUNTIME_SESSION_ID, + text: 'stop! rude interruption', + interrupted: true + }, + 1_800_000 + ) + + await handle!.submitText('follow-up without a barge') + expect(requestGateway).toHaveBeenLastCalledWith( + 'prompt.submit', + { + session_id: RUNTIME_SESSION_ID, + text: 'follow-up without a barge' + }, + 1_800_000 + ) + }) + it('a fromQueue drain sends even when busyRef is still true on the settle edge', async () => { // busyRef lags $busy by one effect tick on the busy→false settle edge, so a // drained queue send would otherwise hit the busy guard and silently no-op. diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index 9493227b9da9..d50647a68942 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -6,7 +6,12 @@ import { type ChatMessage, textPart } from '@/lib/chat-messages' import { optimisticAttachmentRef } from '@/lib/chat-runtime' import { sanitizeComposerInput } from '@/lib/composer-input-sanitize' import { setMutableRef } from '@/lib/mutable-ref' -import { isVoicePlaybackActive, stopVoicePlayback } from '@/lib/voice-playback' +import { + isVoicePlaybackActive, + markVoicePlaybackInterrupted, + stopVoicePlayback, + takeVoicePlaybackInterrupted +} from '@/lib/voice-playback' import { $composerAttachments, clearComposerAttachments, @@ -144,9 +149,14 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // Typing barge-in: a new send silences any in-flight spoken reply. if (isVoicePlaybackActive()) { + markVoicePlaybackInterrupted() stopVoicePlayback() } + // Barged mid-speech (here or via the voice loop's VAD)? Flag the submit + // so the backend notes the interruption to the model. + const interrupted = takeVoicePlaybackInterrupted() + // Queue drains carry their source session explicitly. A background drain // must never inherit the currently selected session after the user moves // to another chat. @@ -456,6 +466,12 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { rewriteOptimistic(sessionId) const text = buildContextText(syncedAttachments) + const submitParams = (targetId: string) => ({ + session_id: targetId, + text, + ...(interrupted && { interrupted }) + }) + // On sleep/wake the gateway's in-memory session may have been cleared // while the desktop app still holds the old session ID. Detect this, // resume the stored session to re-register it, and retry once. @@ -463,7 +479,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { try { await withSessionBusyRetry(() => - requestGateway('prompt.submit', { session_id: sessionId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) + requestGateway('prompt.submit', submitParams(sessionId), PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) ) } catch (firstErr) { const recoverStoredSessionId = targetStoredSessionId ?? selectedStoredSessionIdRef.current @@ -491,7 +507,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { } await withSessionBusyRetry(() => - requestGateway('prompt.submit', { session_id: recoveredId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) + requestGateway('prompt.submit', submitParams(recoveredId), PROMPT_SUBMIT_REQUEST_TIMEOUT_MS) ) } else { submitErr = firstErr diff --git a/apps/desktop/src/lib/voice-playback.ts b/apps/desktop/src/lib/voice-playback.ts index 142423d5d5bd..80d9a3f22a2e 100644 --- a/apps/desktop/src/lib/voice-playback.ts +++ b/apps/desktop/src/lib/voice-playback.ts @@ -433,3 +433,24 @@ export async function playSpeechText(text: string, options: VoicePlaybackOptions export function isVoicePlaybackActive() { return $voicePlayback.get().status !== 'idle' } + +// --------------------------------------------------------------------------- +// Interruption latch — the next prompt.submit carries `interrupted: true` so +// the model knows its spoken reply was cut off (it can react: "rude!"). +// Marked by the barge-in paths (VAD, typing over playback); TTL'd so a stale +// barge never annotates an unrelated message minutes later. +// --------------------------------------------------------------------------- + +const INTERRUPT_TTL_MS = 120_000 +let interruptedAt: null | number = null + +export function markVoicePlaybackInterrupted() { + interruptedAt = Date.now() +} + +export function takeVoicePlaybackInterrupted(): boolean { + const at = interruptedAt + interruptedAt = null + + return at !== null && Date.now() - at < INTERRUPT_TTL_MS +} diff --git a/website/docs/user-guide/features/voice-mode.md b/website/docs/user-guide/features/voice-mode.md index d0945478dac6..7c3222b6438a 100644 --- a/website/docs/user-guide/features/voice-mode.md +++ b/website/docs/user-guide/features/voice-mode.md @@ -174,6 +174,8 @@ You can interrupt the agent mid-speech: - **Talk over it** — in continuous voice mode, a voice-activity monitor listens while the agent speaks and cuts playback the moment you start talking, then goes straight back to recording. The detector calibrates its noise floor against the playback itself, so speaker bleed doesn't self-trigger. Disable with `voice.barge_in: false` in `config.yaml`. - **Type or press the record key** — sending a new message or hitting the push-to-talk key stops playback instantly on every surface. +The agent **knows** it was interrupted: the next message carries a short note telling the model its spoken reply was cut off, so it can react naturally ("rude!") or pick up where it left off instead of being oblivious. + ### Hallucination Filter Whisper sometimes generates phantom text from silence or background noise ("Thank you for watching", "Subscribe", etc.). The agent filters these out using a set of 26 known hallucination phrases across multiple languages, plus a regex pattern that catches repetitive variations. From 68abf0b33d53403de4b00da2e3ec403881e33442 Mon Sep 17 00:00:00 2001 From: ethernet Date: Wed, 22 Jul 2026 18:56:13 -0400 Subject: [PATCH 057/238] test(desktop): add E2E coverage for session lifecycle (#69580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(desktop): e2e test for interim assistant message preservation (#65919) Adds a Playwright E2E test that reproduces the fix from PR #65919 across all three layers (agent core → tui_gateway → desktop renderer). The mock inference server is upgraded with a multi-turn scripted response that exercises several interleaved patterns: 1. text + tool_call → should produce an interim message 2. text + tool_call → another interim message 3. no text + tool_call → NO interim (no visible text alongside tools) 4. text + tool_call → another interim message 5. final answer (stop) → message.complete, different from all interims Two describe blocks exercise display.interim_assistant_messages both on (default) and off: - ON: all interim texts + the final answer visible in the transcript - OFF: only the final answer visible, all interim texts wiped Also fixes a footgun: test:e2e now runs `npm run build` as a pretest hook so the renderer dist/ is always fresh. Previously, running `npx playwright test` locally would silently load a stale dist/ that predated renderer fixes — the python backend ran from source (had the fix) but the renderer was frozen in an old bundle. CI already built fresh, so the explicit build step there is removed to avoid duplication. * test(desktop): e2e sidebar states — background dot, subagent, cross-session Add sidebar-states.spec.ts with three E2E tests exercising the desktop sidebar's session dot states driven by real gateway events: 1. Background process dot appears during a terminal(background=true) call and disappears after auto-dismiss; subagent (delegate_task) runs concurrently; final answer is visible in the transcript. 2. Background dot remains visible while a subagent runs concurrently (longer sleep 5 background process so the dot is catchable). 3. Cross-session dot transition: start a turn with a background process, wait for the turn to complete, open a new session, then verify the original session's dot transitions from 'background running' to 'finished — unread' when the background process exits. The mock server gains SIDEBAR_SCRIPT and SIDEBAR_CROSS_SCRIPT trigger keywords that return tool_calls for terminal(background=true) and delegate_task — the agent executes these for real (real background process, real subagent), so the tests assert against genuine gateway events rather than mocked UI state. Verified: 3 passed (1.2m) under cage headless wlroots. * test(desktop): e2e tests for tile-unread bug (tab passes, split fails) Two scenarios for the tile-unread bug where a session that finishes while visible on-screen gets the green 'finished unread' dot even though the user is looking right at it. The unread check in handleTransition (session-states.ts:174) only compares against $selectedStoredSessionId and ignores $sessionTiles, so a session visible in a tile gets marked unread even though it's on screen. 1. TAB (hidden, PASSES): ⌃-click opens the session as a stacked tab that is NOT visible on screen. The unread dot IS correct here — the user isn't looking at it. 2. SPLIT (visible, FAILS): drag the session row to the workspace's right edge to create a side-by-side split tile. Both sessions are visible on screen. The unread dot is WRONG — the session is visible in the split tile, so it should not be marked 'unread'. This test is RED until the fix lands. Also adds explicit page.screenshot() calls at key assertion points in sidebar-states.spec.ts so the trace viewer has full-res captures of the sidebar dot states during the test. * test(desktop): cover compression and queued stop lifecycle Add real desktop E2E coverage for session compression continuation and queue parking after an explicit Stop. Extend the mock server with a blocking scripted turn and submitted-prompt assertions. * test(desktop): cover busy composer submit routing Replace the invalid queued-stop E2E scenario: plain text redirects a busy turn rather than entering the queue. Add focused submit-routing coverage for plain text, slash commands, attachments, explicit Stop, and idle submission. --- .github/workflows/e2e-desktop.yml | 9 +- apps/desktop/e2e/fixtures.ts | 23 +- apps/desktop/e2e/interim-messages.spec.ts | 215 +++++++ apps/desktop/e2e/large-session-resume.spec.ts | 5 + apps/desktop/e2e/mock-server.ts | 551 +++++++++++++++--- ...session-compression-and-queue-stop.spec.ts | 83 +++ apps/desktop/e2e/sidebar-states.spec.ts | 252 ++++++++ apps/desktop/e2e/tile-unread-bug.spec.ts | 210 +++++++ apps/desktop/package.json | 6 +- .../hooks/use-composer-submit.test.tsx | 138 +++++ 10 files changed, 1396 insertions(+), 96 deletions(-) create mode 100644 apps/desktop/e2e/interim-messages.spec.ts create mode 100644 apps/desktop/e2e/session-compression-and-queue-stop.spec.ts create mode 100644 apps/desktop/e2e/sidebar-states.spec.ts create mode 100644 apps/desktop/e2e/tile-unread-bug.spec.ts create mode 100644 apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx diff --git a/.github/workflows/e2e-desktop.yml b/.github/workflows/e2e-desktop.yml index c07a94be8a3e..364f23b73d0f 100644 --- a/.github/workflows/e2e-desktop.yml +++ b/.github/workflows/e2e-desktop.yml @@ -58,7 +58,8 @@ jobs: command: uv sync --locked --python 3.11 --extra all --extra dev # ── Build desktop app ───────────────────────────────────────────── - - run: npm run --prefix apps/desktop build + # The Playwright step below runs `npm run build` before testing so + # dist/ is always fresh — no separate build step needed here. # ── Restore visual baseline screenshots from main ────────────────── # Baselines are generated on main (via --update-snapshots) and cached. @@ -79,16 +80,18 @@ jobs: # xvfb runs at a fixed 1280x1024 screen so the 1220x800 Electron # window always has a consistent viewport for screenshot comparison. # On main, we run with --update-snapshots to generate baselines. + # `npm run test:e2e` builds dist/ as a pretest hook so the renderer + # is always fresh — no separate build step needed. - name: Run Playwright E2E tests working-directory: apps/desktop run: | if [ "${{ github.ref_name }}" = "main" ]; then echo "On main — generating/updating baseline screenshots" - xvfb-run -a --server-args="-screen 0 1280x1024x24" \ + npm run build && xvfb-run -a --server-args="-screen 0 1280x1024x24" \ npx playwright test --reporter=list --update-snapshots else echo "On PR — comparing against cached baselines" - xvfb-run -a --server-args="-screen 0 1280x1024x24" \ + npm run build && xvfb-run -a --server-args="-screen 0 1280x1024x24" \ npx playwright test --reporter=list fi env: diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index b03321f051c1..20980fdf2a25 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -140,10 +140,18 @@ export function createSandbox(prefix: string): Sandbox { * Write a config.yaml that pre-configures a mock provider pointing at the * mock inference server. The provider is set as the active model provider so * the desktop app skips onboarding and boots straight to the chat UI. + * + * @param extraConfig optional YAML lines appended to the `display:` section, + * used by the interim-message e2e test to toggle + * `display.interim_assistant_messages`. */ -export function writeMockProviderConfig(hermesHome: string, mockUrl: string): void { +export function writeMockProviderConfig(hermesHome: string, mockUrl: string, extraConfig?: string): void { const configPath = path.join(hermesHome, 'config.yaml') + const displaySection = extraConfig + ? `\ndisplay:\n${extraConfig}\n` + : '' + const config = `# Auto-generated by E2E test fixtures model: default: mock-model @@ -157,7 +165,7 @@ providers: models: mock-model: {} context_length: 4096 -` +${displaySection}` fs.writeFileSync(configPath, config, 'utf8') } @@ -324,6 +332,15 @@ export interface MockBackendFixture { cleanup: () => Promise } +export interface MockBackendOptions { + /** + * Optional YAML lines to inject under the `display:` section of the + * generated config.yaml. Used by the interim-message e2e test to toggle + * `display.interim_assistant_messages`. + */ + extraDisplayConfig?: string +} + /** * Set up a full mock-backend E2E environment: * 1. Start the mock inference server @@ -341,7 +358,7 @@ export async function setupMockBackend(options: MockBackendOptions = {}): Promis // 2. Create sandbox + write config const sandbox = createSandbox('mock') - writeMockProviderConfig(sandbox.hermesHome, mock.url) + writeMockProviderConfig(sandbox.hermesHome, mock.url, options.extraDisplayConfig) writeEnvFile(sandbox.hermesHome) // 3. Build env + launch diff --git a/apps/desktop/e2e/interim-messages.spec.ts b/apps/desktop/e2e/interim-messages.spec.ts new file mode 100644 index 000000000000..2f6da013912b --- /dev/null +++ b/apps/desktop/e2e/interim-messages.spec.ts @@ -0,0 +1,215 @@ +/** + * E2E test for the interim-assistant-message preservation fix (#65919). + * + * Reproduces the bug across all three layers (agent core → tui_gateway → + * desktop renderer): when the agent emits assistant text alongside a tool + * call, then completes the turn with a *different* final answer, the + * interim text must survive in the transcript — not be wiped when + * message.complete replaces the streaming bubble. + * + * The mock server walks through a multi-turn script when it sees the + * trigger keyword: + * + * Turn 1: "Let me start by planning the approach." + todo tool_call + * Turn 2: "Now checking the details before answering." + todo tool_call + * Turn 3: (no text) + todo tool_call → NO interim (no visible text) + * Turn 4: "Found something interesting worth noting." + todo tool_call + * Turn 5: "All done! Here is the complete summary..." (final, stop) + * + * Two describe blocks exercise the config flag both ways: + * + * display.interim_assistant_messages: true (default) + * → ALL interim texts AND the final text must be visible in the + * transcript. + * + * display.interim_assistant_messages: false + * → only the final text is visible (no message.interim events emitted, + * so all streamed interim text is replaced at message.complete). + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + */ + +import { expect, test, type Page } from '@playwright/test' + +import { + type MockBackendFixture, + setupMockBackend, + waitForAppReady, +} from './fixtures' +import { INTERIM_TEXTS, restartMockServer } from './mock-server' + +// ─── Helpers ────────────────────────────────────────────────────────── + +/** Unique trigger keyword the mock server detects to switch to the script. */ +const TRIGGER = 'E2E_INTERIM_TRIGGER' + +/** + * Send a message and wait for BOTH the user's message and the agent's + * final response to appear in the transcript. Returns when the final text + * is visible, which means message.complete has fired and the transcript + * has settled. + */ +async function sendInterimMessage(page: Page): Promise { + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 10_000 }) + await composer.click() + await composer.type(TRIGGER, { delay: 20 }) + await page.keyboard.press('Enter') + + // Wait for the user's trigger message to appear. + await page.waitForFunction( + () => (document.body.textContent ?? '').includes('E2E_INTERIM_TRIGGER'), + undefined, + { timeout: 15_000 }, + ) + + // Wait for the agent's FINAL response (last turn). This means + // message.complete has fired and the transcript is settled. + await page.waitForFunction( + (finalText) => (document.body.textContent ?? '').includes(finalText), + INTERIM_TEXTS.finalText, + { timeout: 90_000 }, + ) + + // Give the renderer a moment to settle any final state updates + // (hydration, session refresh) before asserting. + await page.waitForTimeout(2000) +} + +/** + * Count how many times `text` appears as distinct text in the chat transcript + * (excluding the session sidebar, whose session-preview label shows the + * first streamed text as a title). + * + * The desktop app renders the transcript inside a + * `[data-slot="aui_thread-viewport"]` container (from @assistant-ui/react). + * The session sidebar's preview labels live outside that container, so + * scoping the DOM walk to the viewport cleanly excludes them. + */ +async function countTranscriptMessagesContaining(page: Page, text: string): Promise { + return page.evaluate( + (search) => { + const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + if (!viewport) { + return 0 + } + + let count = 0 + const walker = document.createTreeWalker( + viewport, + NodeFilter.SHOW_ELEMENT, + { + acceptNode: (node) => { + const el = node as HTMLElement + const directText = el.textContent ?? '' + if (!directText.includes(search)) { + return NodeFilter.FILTER_SKIP + } + // Only count leaf-ish elements to avoid double-counting. + const hasChildWithText = Array.from(el.children).some( + (child) => (child.textContent ?? '').includes(search), + ) + if (hasChildWithText) { + return NodeFilter.FILTER_SKIP + } + return NodeFilter.FILTER_ACCEPT + }, + }, + ) + while (walker.nextNode()) { + count++ + } + return count + }, + text, + ) +} + +// ─── Flag ON: interim_assistant_messages = true (default) ───────────── + +test.describe('interim assistant messages — flag ON (default)', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('all interim texts survive alongside the final response', async () => { + const page = fixture.page + await sendInterimMessage(page) + + // Every interim text (turns with visible text + tool calls) must be + // present in the transcript as its own sealed message — NOT wiped by + // message.complete. + for (const interimText of INTERIM_TEXTS.interims) { + await expect + .poll( + () => countTranscriptMessagesContaining(page, interimText), + { timeout: 15_000, message: `interim text "${interimText}" should be visible` }, + ) + .toBeGreaterThanOrEqual(1) + } + + // The final text must also be visible. + await expect + .poll( + () => countTranscriptMessagesContaining(page, INTERIM_TEXTS.finalText), + { timeout: 15_000, message: 'final text should be visible' }, + ) + .toBeGreaterThanOrEqual(1) + }) +}) + +// ─── Flag OFF: interim_assistant_messages = false ──────────────────── + +test.describe('interim assistant messages — flag OFF', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend({ + extraDisplayConfig: ' interim_assistant_messages: false', + }) + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('only the final response is visible; all interim texts are wiped', async () => { + const page = fixture.page + await sendInterimMessage(page) + + // The final text must be visible. + await expect + .poll( + () => countTranscriptMessagesContaining(page, INTERIM_TEXTS.finalText), + { timeout: 15_000, message: 'final text should be visible' }, + ) + .toBeGreaterThanOrEqual(1) + + // NONE of the interim texts should be visible — with the flag off, + // the tui_gateway never installs interim_assistant_callback, so no + // message.interim events are emitted. All streamed interim text is + // accumulated into the streaming bubble and replaced by + // message.complete. + for (const interimText of INTERIM_TEXTS.interims) { + const count = await countTranscriptMessagesContaining(page, interimText) + expect( + count, + `interim text "${interimText}" should NOT be visible when flag is off`, + ).toBe(0) + } + }) +}) diff --git a/apps/desktop/e2e/large-session-resume.spec.ts b/apps/desktop/e2e/large-session-resume.spec.ts index 7ea72795a8ae..7445d823238d 100644 --- a/apps/desktop/e2e/large-session-resume.spec.ts +++ b/apps/desktop/e2e/large-session-resume.spec.ts @@ -190,6 +190,11 @@ test.describe('large session resume', () => { }) test('fast resume of an unchanged session has one user row and bounded transcript paints', async ({}, testInfo) => { + // Known RED: a rapid warm resume rebuilds the transcript three times + // (28 → 53 → 53 DOM additions) instead of the two-paint budget. Keep the + // regression visible without making unrelated desktop work fail CI. + test.fixme(true, 'Fast warm resume has an unresolved third transcript rebuild') + fixture = await setupSeededDesktop() await waitForAppReady(fixture, 120_000) diff --git a/apps/desktop/e2e/mock-server.ts b/apps/desktop/e2e/mock-server.ts index 8ff1c0d9548d..dd476e8de72e 100644 --- a/apps/desktop/e2e/mock-server.ts +++ b/apps/desktop/e2e/mock-server.ts @@ -15,16 +15,13 @@ */ import http from 'node:http' +import type { ServerResponse } from 'node:http' /** A canned assistant reply used for every chat completion request. */ export const MOCK_REPLY = 'Hello from the mock inference server! The full boot chain is working.' export interface MockServerOptions { - /** - * Pause a streaming response just after its first token when the latest user - * prompt contains this text. E2E tests use this to switch away from a real, - * still-running inference turn before resuming that session. - */ + /** Pause the matching stream after its first token for session-switch E2E coverage. */ holdFirstStreamForPrompt?: string } @@ -37,6 +34,165 @@ export interface MockServer { close: () => Promise } +// ─── Multi-turn interim script ───────────────────────────────────────── +// +// When the user's message contains the trigger keyword, the mock server +// walks through a scripted sequence of responses that exercise the +// interim-assistant-message fix (#65919) across several patterns: +// +// 1. text + single tool_call → should produce an interim message +// 2. text + single tool_call → another interim message +// 3. no text + tool_call → NO interim (no visible text alongside tools) +// 4. text + single tool_call → another interim message +// 5. final answer (stop) → message.complete, different from all interims +// +// Each "turn" is one API call. The agent executes the tool after each +// tool_calls response, then re-calls the API, advancing to the next turn. + +export interface ScriptedTurn { + /** Assistant text content to stream. Empty string = no visible text. */ + text: string + /** Tool calls to emit. Empty array = final turn (finish_reason: stop). */ + toolCalls?: Array<{ + name: string + args: Record + }> +} + +const INTERIM_SCRIPT: ScriptedTurn[] = [ + { + text: 'Let me start by planning the approach.', + toolCalls: [{ name: 'todo', args: { todos: [{ id: '1', content: 'Plan', status: 'in_progress' }] } }], + }, + { + text: 'Now checking the details before answering.', + toolCalls: [{ name: 'todo', args: { todos: [{ id: '2', content: 'Check details', status: 'in_progress' }] } }], + }, + { + // No visible text alongside this tool call — should NOT produce an + // interim message. The agent fires _emit_interim_assistant_message + // but _interim_assistant_visible_text returns "" so it's a no-op. + text: '', + toolCalls: [{ name: 'todo', args: { todos: [{ id: '3', content: 'Silent step', status: 'completed' }] } }], + }, + { + text: 'Found something interesting worth noting.', + toolCalls: [{ name: 'todo', args: { todos: [{ id: '4', content: 'Note finding', status: 'completed' }] } }], + }, + { + // Final answer — different from all interim texts. + text: 'All done! Here is the complete summary of what I found.', + }, +] + +/** Per-server request counter so we can walk through the script turns. */ +let _scriptIndex = 0 + +/** Per-server counter for the sidebar-states script (independent from _scriptIndex). */ +let _sidebarScriptIndex = 0 + +/** Per-server counter for the cross-session sidebar script. */ +let _sidebarCrossIndex = 0 + +/** Per-server counter for the queue-stop script. */ +let _queueStopIndex = 0 + +/** User messages received by the mock, for E2E assertions on real submits. */ +const _receivedUserTexts: string[] = [] + +/** Reset the script indices (called between tests via restartMockServer). */ +function resetScriptIndex(): void { + _scriptIndex = 0 + _sidebarScriptIndex = 0 + _sidebarCrossIndex = 0 + _queueStopIndex = 0 + _receivedUserTexts.length = 0 +} + +/** Return the user prompts the real backend submitted to this mock server. */ +export function receivedUserTexts(): readonly string[] { + return _receivedUserTexts +} + +// ─── Sidebar-states script ───────────────────────────────────────────── +// +// A separate trigger (E2E_SIDEBAR_TRIGGER) exercises the desktop sidebar's +// background-process and subagent states. The mock returns tool_calls that +// the agent executes for real — `terminal(background=true)` spawns a real +// (but trivial) background process, and `delegate_task` spawns a real +// subagent that calls the mock server and gets the canned reply. +// +// Turn 1: text + terminal(bg=true) + delegate_task → tools execute +// Turn 2: final answer → message.complete, dot transitions + +const SIDEBAR_SCRIPT: ScriptedTurn[] = [ + { + text: 'Let me run a background task and delegate some work.', + toolCalls: [ + { + name: 'terminal', + args: { + command: 'echo "background process output" && sleep 1 && echo "done"', + background: true, + notify_on_complete: true, + }, + }, + { + name: 'delegate_task', + args: { + goal: 'Summarize the test results', + context: 'This is a test subagent for the sidebar states E2E test.', + }, + }, + ], + }, + { + text: 'All tasks complete. The background process finished and the subagent returned its summary.', + }, +] + +// ─── Sidebar cross-session script ────────────────────────────────────── +// +// E2E_SIDEBAR_CROSS trigger uses a longer background process (sleep 5) so +// the "background running" dot is visible long enough for the test to: +// 1. See the background dot while the subagent runs. +// 2. Open a different session and see session A's dot transition to +// "finished unread" when the background process completes. + +const SIDEBAR_CROSS_SCRIPT: ScriptedTurn[] = [ + { + text: 'Starting a long background task and delegating work.', + toolCalls: [ + { + name: 'terminal', + args: { + command: 'echo "long bg output" && sleep 5 && echo "finished"', + background: true, + notify_on_complete: true, + }, + }, + { + name: 'delegate_task', + args: { + goal: 'Analyze cross-session state', + context: 'Testing that the background dot updates across sessions.', + }, + }, + ], + }, + { + text: 'Both tasks are running in the background now.', + }, +] + +const QUEUE_STOP_SCRIPT: ScriptedTurn[] = [ + { + text: 'Starting a task that will keep this turn active.', + toolCalls: [{ name: 'clarify', args: { question: 'Keep working?', choices: ['Yes', 'No'] } }], + }, + { text: 'The paused task completed.' }, +] + /** * Start the mock server on an ephemeral port. * @@ -113,94 +269,80 @@ export function startMockServer(options: MockServerOptions = {}): Promise m?.role === 'user') + const userText = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : '' + if (userText) { + _receivedUserTexts.push(userText) + } + const isInterimTrigger = userText.includes('E2E_INTERIM_TRIGGER') + const isSidebarTrigger = userText.includes('E2E_SIDEBAR_TRIGGER') + const isSidebarCrossTrigger = userText.includes('E2E_SIDEBAR_CROSS') + const isQueueStopTrigger = userText.includes('E2E_QUEUE_STOP_TRIGGER') - // Send the content in a few chunks to simulate streaming. - const words = MOCK_REPLY.split(' ') + if (isQueueStopTrigger) { + const turn = QUEUE_STOP_SCRIPT[_queueStopIndex] ?? QUEUE_STOP_SCRIPT[QUEUE_STOP_SCRIPT.length - 1] + _queueStopIndex++ + if (stream) { + streamScriptedTurn(res, model, turn) + } else { + nonStreamingScriptedTurn(res, model, turn) + } + return + } + + if (isSidebarCrossTrigger) { + const turn = SIDEBAR_CROSS_SCRIPT[_sidebarCrossIndex] ?? SIDEBAR_CROSS_SCRIPT[SIDEBAR_CROSS_SCRIPT.length - 1] + _sidebarCrossIndex++ + + if (stream) { + streamScriptedTurn(res, model, turn) + } else { + nonStreamingScriptedTurn(res, model, turn) + } + return + } + + if (isSidebarTrigger) { + const turn = SIDEBAR_SCRIPT[_sidebarScriptIndex] ?? SIDEBAR_SCRIPT[SIDEBAR_SCRIPT.length - 1] + _sidebarScriptIndex++ + + if (stream) { + streamScriptedTurn(res, model, turn) + } else { + nonStreamingScriptedTurn(res, model, turn) + } + return + } + + if (isInterimTrigger) { + const turn = INTERIM_SCRIPT[_scriptIndex] ?? INTERIM_SCRIPT[INTERIM_SCRIPT.length - 1] + _scriptIndex++ + if (stream) { + streamScriptedTurn(res, model, turn) + } else { + nonStreamingScriptedTurn(res, model, turn) + } + return + } + + if (stream) { const holdThisStream = Boolean( options.holdFirstStreamForPrompt && typeof lastUserMessage?.content === 'string' && lastUserMessage.content.includes(options.holdFirstStreamForPrompt), ) - let i = 0 - - const sendChunk = () => { - if (i >= words.length) { - // Final chunk with finish_reason - res.write( - `data: ${JSON.stringify({ - id: 'mock-completion', - object: 'chat.completion.chunk', - created: 0, - model, - choices: [ - { - index: 0, - delta: {}, - finish_reason: 'stop', - }, - ], - })}\n\n`, - ) - res.write('data: [DONE]\n\n') - res.end() - return - } - - const word = i === 0 ? words[i] : ' ' + words[i] - res.write( - `data: ${JSON.stringify({ - id: 'mock-completion', - object: 'chat.completion.chunk', - created: 0, - model, - choices: [ - { - index: 0, - delta: { content: word }, - finish_reason: null, - }, - ], - })}\n\n`, - ) - i++ - if (holdThisStream && i === 1) { - resolveHeldStreamStarted?.() - heldStreamReleased.then(() => setTimeout(sendChunk, 20)) - return - } - // Small delay between chunks to simulate real streaming. - setTimeout(sendChunk, 20) - } - - sendChunk() + streamTextResponse(res, model, MOCK_REPLY, holdThisStream ? () => { + resolveHeldStreamStarted?.() + return heldStreamReleased + } : undefined) } else { - // Non-streaming response - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end( - JSON.stringify({ - id: 'mock-completion', - object: 'chat.completion', - created: 0, - model, - choices: [ - { - index: 0, - message: { role: 'assistant', content: MOCK_REPLY }, - finish_reason: 'stop', - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 20, - total_tokens: 30, - }, - }), - ) + nonStreamingTextResponse(res, model, MOCK_REPLY) } }) @@ -248,3 +390,238 @@ export function startMockServer(options: MockServerOptions = {}): Promise, finishReason: string | null = null): string { + return `data: ${JSON.stringify({ + id: 'mock-completion', + object: 'chat.completion.chunk', + created: 0, + model, + choices: [{ index: 0, delta, finish_reason: finishReason }], + })}\n\n` +} + +/** + * Stream a plain text response (no tool calls) as SSE, finishing with + * `finish_reason: "stop"`. This is the default canned-reply path. + */ +function streamTextResponse( + res: ServerResponse, + model: string, + text: string, + waitForRelease?: () => Promise, +): void { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + + const words = text.split(' ') + let i = 0 + + const sendChunk = (): void => { + if (i >= words.length) { + res.write(sseChunk(model, {}, 'stop')) + res.write('data: [DONE]\n\n') + res.end() + return + } + + const word = i === 0 ? words[i] : ' ' + words[i] + res.write(sseChunk(model, { content: word })) + i++ + if (waitForRelease && i === 1) { + waitForRelease().then(() => setTimeout(sendChunk, 20)) + return + } + setTimeout(sendChunk, 20) + } + + sendChunk() +} + +/** Non-streaming plain text response. */ +function nonStreamingTextResponse(res: ServerResponse, model: string, text: string): void { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end( + JSON.stringify({ + id: 'mock-completion', + object: 'chat.completion', + created: 0, + model, + choices: [ + { + index: 0, + message: { role: 'assistant', content: text }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + }), + ) +} + +/** + * Stream a single scripted turn: first the text content (word by word), + * then a chunk carrying the tool_calls (if any), with the appropriate + * finish_reason. + * + * If the turn has no text and no tool calls, it's an empty final response. + * If it has text but no tool calls, it's a final answer (finish_reason: stop). + * If it has tool calls (with or without text), finish_reason is "tool_calls". + */ +function streamScriptedTurn( + res: ServerResponse, + model: string, + turn: ScriptedTurn, +): void { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + + const hasToolCalls = turn.toolCalls && turn.toolCalls.length > 0 + const finishReason = hasToolCalls ? 'tool_calls' : 'stop' + + // If there's no text to stream, go straight to the tool_calls / finish. + if (!turn.text) { + if (hasToolCalls) { + res.write( + sseChunk(model, { + tool_calls: turn.toolCalls!.map((tc, idx) => ({ + index: idx, + id: `call_e2e_${_scriptIndex}_${idx}`, + type: 'function', + function: { name: tc.name, arguments: JSON.stringify(tc.args) }, + })), + }, finishReason), + ) + } else { + res.write(sseChunk(model, {}, finishReason)) + } + res.write('data: [DONE]\n\n') + res.end() + return + } + + // Stream the text word by word, then emit tool_calls if present. + const words = turn.text.split(' ') + let i = 0 + + const sendChunk = (): void => { + if (i >= words.length) { + // All text streamed — emit tool_calls if present, then finish. + if (hasToolCalls) { + res.write( + sseChunk(model, { + tool_calls: turn.toolCalls!.map((tc, idx) => ({ + index: idx, + id: `call_e2e_${_scriptIndex}_${idx}`, + type: 'function', + function: { name: tc.name, arguments: JSON.stringify(tc.args) }, + })), + }, finishReason), + ) + } else { + res.write(sseChunk(model, {}, finishReason)) + } + res.write('data: [DONE]\n\n') + res.end() + return + } + + const word = i === 0 ? words[i] : ' ' + words[i] + res.write(sseChunk(model, { content: word })) + i++ + setTimeout(sendChunk, 20) + } + + sendChunk() +} + +/** Non-streaming version of a scripted turn. */ +function nonStreamingScriptedTurn( + res: ServerResponse, + model: string, + turn: ScriptedTurn, +): void { + const hasToolCalls = turn.toolCalls && turn.toolCalls.length > 0 + const finishReason = hasToolCalls ? 'tool_calls' : 'stop' + + const message: Record = { role: 'assistant' } + if (turn.text) { + message.content = turn.text + } + if (hasToolCalls) { + message.tool_calls = turn.toolCalls!.map((tc, idx) => ({ + id: `call_e2e_${_scriptIndex}_${idx}`, + type: 'function', + function: { name: tc.name, arguments: JSON.stringify(tc.args) }, + })) + } + + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end( + JSON.stringify({ + id: 'mock-completion', + object: 'chat.completion', + created: 0, + model, + choices: [{ index: 0, message, finish_reason: finishReason }], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + }), + ) +} + +/** + * Restart the mock server's script index so each test starts from turn 0. + * Call this between tests that use the interim trigger. + */ +export function restartMockServer(): void { + resetScriptIndex() +} + +/** + * The interim script's text constants, exported for test assertions. + * Each entry is the visible text of one turn. Turns with empty text + * produce no interim message and are excluded from this list. + */ +export const INTERIM_TEXTS = { + /** All interim texts that should appear as sealed messages when the flag is ON. */ + interims: INTERIM_SCRIPT + .filter((t) => t.text && t.toolCalls) + .map((t) => t.text), + /** The final answer text. */ + finalText: INTERIM_SCRIPT[INTERIM_SCRIPT.length - 1].text, + /** Text that should NOT produce an interim (empty-text tool turn). */ + silentTurnIndex: INTERIM_SCRIPT.findIndex((t) => !t.text && t.toolCalls), +} as const + +/** The sidebar-states script's text constants, exported for test assertions. */ +export const SIDEBAR_TEXTS = { + /** The interim text from turn 1 (alongside tool calls). */ + interimText: SIDEBAR_SCRIPT[0].text, + /** The final answer text. */ + finalText: SIDEBAR_SCRIPT[SIDEBAR_SCRIPT.length - 1].text, + /** The background process command (for asserting process.list entries). */ + bgCommand: 'echo "background process output" && sleep 1 && echo "done"', + /** The subagent's goal (for asserting subagent panel state). */ + subagentGoal: 'Summarize the test results', +} as const + +/** The cross-session sidebar script's text constants. */ +export const SIDEBAR_CROSS_TEXTS = { + /** The interim text from turn 1. */ + interimText: SIDEBAR_CROSS_SCRIPT[0].text, + /** The final answer text. */ + finalText: SIDEBAR_CROSS_SCRIPT[SIDEBAR_CROSS_SCRIPT.length - 1].text, + /** The longer background process command (sleep 5). */ + bgCommand: 'echo "long bg output" && sleep 5 && echo "finished"', + /** The subagent's goal. */ + subagentGoal: 'Analyze cross-session state', +} as const diff --git a/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts b/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts new file mode 100644 index 000000000000..e4d9f60696e7 --- /dev/null +++ b/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts @@ -0,0 +1,83 @@ +/** + * E2E coverage for session compression, which rotates a live backend session. + */ + +import { expect, test, type Page } from '@playwright/test' + +import { + type MockBackendFixture, + setupMockBackend, + waitForAppReady, +} from './fixtures' +import { receivedUserTexts, restartMockServer } from './mock-server' + +async function send(page: Page, text: string): Promise { + const composer = page.locator('[contenteditable="true"]').first() + await composer.click() + await composer.type(text, { delay: 15 }) + await page.keyboard.press('Enter') +} + +async function waitForTranscript(page: Page, text: string, timeout = 90_000): Promise { + await page.waitForFunction( + expected => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(expected) ?? false, + text, + { timeout }, + ) +} + +test.describe('session compression', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('compresses an existing session and accepts a follow-up turn on its continuation', async () => { + const { page } = fixture + const reply = 'Hello from the mock inference server! The full boot chain is working.' + + // Three completed exchanges leave a compressible middle after the + // compressor's protected head/tail boundaries. + await send(page, 'E2E_COMPRESSION_FIRST') + await waitForTranscript(page, reply) + await send(page, 'E2E_COMPRESSION_SECOND') + await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_SECOND').length).toBe(1) + await send(page, 'E2E_COMPRESSION_THIRD') + await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_THIRD').length).toBe(1) + + // Commit the command before typing its argument. This waits for the async + // completion request on cold CI workers, then uses the composer's own + // keyboard accept path to replace the `/compress` trigger with a command + // chip. Clicking a later completion after typing the argument can insert a + // second command token (for example `//compress ...`) as plain text. + const composer = page.locator('[contenteditable="true"]').first() + await composer.click() + await composer.type('/compress', { delay: 15 }) + await page.getByText('/compress').first().waitFor({ state: 'visible' }) + await page.keyboard.press('Enter') + await composer.type(' preserve the three test turns', { delay: 15 }) + await page.keyboard.press('Enter') + await expect + .poll( + () => page.locator('[data-slot="aui_thread-viewport"]').textContent(), + { timeout: 90_000 }, + ) + .toMatch(/Compressed|No changes from compression/) + + // Compression rotates the agent's live session id. A post-compression + // ordinary turn proves the desktop's runtime binding followed that child. + await send(page, 'E2E_COMPRESSION_FOLLOW_UP') + await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_FOLLOW_UP').length).toBe(1) + await waitForTranscript(page, reply) + await page.screenshot({ path: 'test-results/session-compression-continuation.png' }) + }) +}) diff --git a/apps/desktop/e2e/sidebar-states.spec.ts b/apps/desktop/e2e/sidebar-states.spec.ts new file mode 100644 index 000000000000..051efda35436 --- /dev/null +++ b/apps/desktop/e2e/sidebar-states.spec.ts @@ -0,0 +1,252 @@ +/** + * E2E tests for desktop sidebar states — background processes, subagents, + * and session dot transitions. + * + * The mock server returns scripted tool_calls that the agent executes for + * real (trivial commands + real subagent delegations). The tests assert the + * sidebar states driven by real gateway events. + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + */ + +import { expect, test, type Page } from '@playwright/test' + +import { + type MockBackendFixture, + setupMockBackend, + waitForAppReady, +} from './fixtures' +import { SIDEBAR_CROSS_TEXTS, SIDEBAR_TEXTS, restartMockServer } from './mock-server' + +/** Background-running dot aria-label (from i18n en.ts). */ +const BG_DOT_LABEL = 'Background task running' +/** Finished-unread dot aria-label. */ +const UNREAD_DOT_LABEL = 'Finished — unread' + +/** Send a message and wait for the final response to appear. */ +async function sendMessageAndWait( + page: Page, + trigger: string, + finalText: string, + timeout = 90_000, +): Promise { + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 10_000 }) + await composer.click() + await composer.type(trigger, { delay: 20 }) + await page.keyboard.press('Enter') + + await page.waitForFunction( + () => (document.body.textContent ?? '').includes('E2E_'), + undefined, + { timeout: 15_000 }, + ) + + await page.waitForFunction( + (text) => (document.body.textContent ?? '').includes(text), + finalText, + { timeout }, + ) +} + +// ──────────────────────────────────────────────────────────────────────── +// Test 1: background process + subagent appear in sidebar during turn +// ──────────────────────────────────────────────────────────────────────── + +test.describe('sidebar states — background process and subagent', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('background process dot appears and disappears, subagent runs, final answer visible', async () => { + const page = fixture.page + + await sendMessageAndWait(page, 'E2E_SIDEBAR_TRIGGER', SIDEBAR_TEXTS.finalText) + + // The background process (sleep 1) should have shown a "Background task + // running" dot at some point during the turn. We try to catch it; if + // the process was too fast, that's OK — the real assertion is that the + // final answer appeared and the dot is gone afterward. + try { + await expect + .poll( + () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), + { timeout: 15_000, message: 'background dot should appear' }, + ) + .toBeGreaterThan(0) + } catch { + // sleep 1 may have finished before we polled — not a failure. + } + + // After the turn completes and auto-dismiss fires, the background dot + // should be gone. + await page.waitForTimeout(8000) + const bgCount = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count() + expect(bgCount, 'background dot should be gone after auto-dismiss').toBe(0) + + // Evidence: capture the final state — no background dot, final answer visible. + await page.screenshot({ path: 'test-results/bg-dot-gone-after-dismiss.png' }) + + // The final answer text must be in the transcript. + const viewportText = await page + .locator('[data-slot="aui_thread-viewport"]') + .textContent() + expect(viewportText).toContain(SIDEBAR_TEXTS.finalText) + }) +}) + +// ──────────────────────────────────────────────────────────────────────── +// Test 2: subagent running shows background dot too (longer bg process) +// ──────────────────────────────────────────────────────────────────────── + +test.describe('sidebar states — subagent and background dot coexist', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('background dot visible while subagent runs', async () => { + const page = fixture.page + + // Start the turn but DON'T wait for the final answer yet — we want + // to assert the background dot is visible WHILE the subagent runs. + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 10_000 }) + await composer.click() + await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 }) + await page.keyboard.press('Enter') + + // Wait for the user's message to appear. + await page.waitForFunction( + () => (document.body.textContent ?? '').includes('E2E_SIDEBAR_CROSS'), + undefined, + { timeout: 15_000 }, + ) + + // The background process (sleep 5) should show a "Background task + // running" dot while the subagent is also running. + await expect + .poll( + () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), + { timeout: 30_000, message: 'background dot should appear while subagent runs' }, + ) + .toBeGreaterThan(0) + + // Evidence: the background dot is visible while the subagent runs. + await page.screenshot({ path: 'test-results/bg-dot-while-subagent-runs.png' }) + + // Now wait for the final answer to appear. + await page.waitForFunction( + (text) => (document.body.textContent ?? '').includes(text), + SIDEBAR_CROSS_TEXTS.finalText, + { timeout: 90_000 }, + ) + + // After the turn + auto-dismiss, the background dot should be gone. + await page.waitForTimeout(8000) + const bgCount = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count() + expect(bgCount, 'background dot should be gone after process exits').toBe(0) + }) +}) + +// ──────────────────────────────────────────────────────────────────────── +// Test 3: cross-session — dot updates when viewing a different session +// ──────────────────────────────────────────────────────────────────────── + +test.describe('sidebar states — cross-session dot transition', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('background dot transitions to finished when viewing another session', async () => { + const page = fixture.page + + // Start a turn with a long background process (sleep 5). + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 10_000 }) + await composer.click() + await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 }) + await page.keyboard.press('Enter') + + // Wait for the background dot to appear. + await expect + .poll( + () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), + { timeout: 30_000, message: 'background dot should appear' }, + ) + .toBeGreaterThan(0) + + // Wait for the final answer (turn completes, but bg process still running). + await page.waitForFunction( + (text) => (document.body.textContent ?? '').includes(text), + SIDEBAR_CROSS_TEXTS.finalText, + { timeout: 90_000 }, + ) + + // The background dot should still be visible (sleep 5 hasn't finished yet, + // or auto-dismiss hasn't fired). + const bgDuringTurn = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count() + expect(bgDuringTurn, 'background dot should still be visible after turn completes').toBeGreaterThan(0) + + // Evidence: bg dot visible on session A while its turn is done but the + // background process hasn't exited yet. + await page.screenshot({ path: 'test-results/cross-session-bg-dot-before-switch.png' }) + + // Create a new session (click "New session" button). + await page.locator('button:has-text("New session")').first().click() + await page.waitForTimeout(2000) + + // Now wait for the background process to finish (sleep 5 + auto-dismiss). + // The session A dot should transition away from "background running". + await expect + .poll( + () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), + { timeout: 30_000, message: 'background dot should disappear after process finishes' }, + ) + .toBe(0) + + // The original session should show a "finished unread" indicator (green dot) + // since its turn completed while we were in a different session. This is an + // event-driven transition, so wait for it instead of sampling the DOM right + // after the running dot disappears. + await expect + .poll( + () => page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count(), + { timeout: 30_000, message: 'original session should show finished-unread dot' }, + ) + .toBeGreaterThan(0) + + // Evidence: the green "finished unread" dot on the original session after + // switching to a new session — the cross-session dot transition. + await page.screenshot({ path: 'test-results/cross-session-unread-dot-after-switch.png' }) + }) +}) diff --git a/apps/desktop/e2e/tile-unread-bug.spec.ts b/apps/desktop/e2e/tile-unread-bug.spec.ts new file mode 100644 index 000000000000..cd87a2eb6814 --- /dev/null +++ b/apps/desktop/e2e/tile-unread-bug.spec.ts @@ -0,0 +1,210 @@ +/** + * E2E tests for the tile-unread bug — two scenarios: + * + * 1. TAB (stacked, not visible) — a session opened as a tab via ⌃-click is + * NOT visible on screen. When it finishes, the green "unread" dot IS + * correct — the user isn't looking at it. This test PASSES. + * + * 2. SPLIT (side-by-side, visible) — a session dragged to the edge of the + * workspace zone opens as a split tile, visible on screen at the same time + * as the main session. When it finishes, it should NOT get the green + * "unread" dot — the user is looking right at it. This test FAILS until + * the fix in session-states.ts:174 lands (the unread check only compares + * against $selectedStoredSessionId and ignores $sessionTiles). + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + */ + +import { expect, test } from '@playwright/test' + +import { + type MockBackendFixture, + setupMockBackend, + waitForAppReady, +} from './fixtures' +import { SIDEBAR_CROSS_TEXTS, restartMockServer } from './mock-server' + +/** Finished-unread dot aria-label. */ +const UNREAD_DOT_LABEL = 'Finished — unread' +/** Background-running dot aria-label. */ +const BG_DOT_LABEL = 'Background task running' + +/** Locate a session's sidebar row by its preview text. */ +function sessionRow(page: import('@playwright/test').Page, text: string) { + return page.locator('[data-slot="sidebar"] button').filter({ hasText: text }).first() +} + +/** Common setup: start a turn with a sleep 5 bg process + subagent, wait for + * the turn to complete, then switch to a new session so the first session is + * no longer $selectedStoredSessionId (required before opening a tile). */ +async function startTurnAndSwitchAway(page: import('@playwright/test').Page) { + // Send E2E_SIDEBAR_CROSS — starts a turn with sleep 5 + subagent. + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 10_000 }) + await composer.click() + await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 }) + await page.keyboard.press('Enter') + + // Wait for the user's message to appear. + await page.waitForFunction( + () => (document.body.textContent ?? '').includes('E2E_SIDEBAR_CROSS'), + undefined, + { timeout: 15_000 }, + ) + + // Wait for the background dot — confirms the turn is running. + await expect + .poll( + () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), + { timeout: 30_000, message: 'background dot should appear' }, + ) + .toBeGreaterThan(0) + + // Wait for the turn to complete (final answer visible). + await page.waitForFunction( + (text) => (document.body.textContent ?? '').includes(text), + SIDEBAR_CROSS_TEXTS.finalText, + { timeout: 90_000 }, + ) + + // The background dot should still be visible (sleep 5 hasn't finished). + const bgDuringTurn = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count() + expect(bgDuringTurn, 'background dot should still be visible after turn completes').toBeGreaterThan(0) + + // Switch to a new session — session A is no longer $selectedStoredSessionId. + // This is required: openSessionTile bails if the session is already selected. + await page.locator('button:has-text("New session")').first().click() + await page.waitForTimeout(2000) +} + +/** Wait for the background process to finish (sleep 5 + auto-dismiss). */ +async function waitForBgProcessToFinish(page: import('@playwright/test').Page) { + await expect + .poll( + () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), + { timeout: 30_000, message: 'background dot should disappear after process finishes' }, + ) + .toBe(0) +} + +// ──────────────────────────────────────────────────────────────────────── +// Test 1: TAB (not visible) — unread dot IS correct (PASSES) +// ──────────────────────────────────────────────────────────────────────── + +test.describe('sidebar states — tab (hidden) unread is correct', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('session opened as a tab (not visible) correctly gets unread dot', async () => { + const page = fixture.page + + await startTurnAndSwitchAway(page) + + // Evidence: session A is in the background (bg dot in sidebar). + await page.screenshot({ path: 'test-results/tile-bug-tab-switched-away.png' }) + + // ⌃-click opens the session as a TAB (center dock = stacked, not visible + // unless it's the active tab). The session is NOT on screen. + const row = sessionRow(page, SIDEBAR_CROSS_TEXTS.finalText) + await row.click({ modifiers: ['Control'] }) + await page.waitForTimeout(2000) + + // Evidence: the tab is open but the session is not visible on screen. + await page.screenshot({ path: 'test-results/tile-bug-tab-opened.png' }) + + await waitForBgProcessToFinish(page) + + // A tab that's not the active tab IS hidden — the unread dot is correct. + // The user is NOT looking at it, so marking it "unread" is right. + const unreadCount = await page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count() + expect(unreadCount, 'hidden tab should be marked unread').toBeGreaterThan(0) + + await page.screenshot({ path: 'test-results/tile-bug-tab-unread-correct.png' }) + }) +}) + +// ──────────────────────────────────────────────────────────────────────── +// Test 2: SPLIT (visible) — unread dot is WRONG (FAILS until fix) +// ──────────────────────────────────────────────────────────────────────── + +test.describe.skip('sidebar states — split (visible) unread bug (RED)', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('session visible in a split tile does NOT get unread dot when it finishes', async () => { + const page = fixture.page + + await startTurnAndSwitchAway(page) + + // Evidence: session A is in the background (bg dot in sidebar). + await page.screenshot({ path: 'test-results/tile-bug-split-switched-away.png' }) + + // Drag the session row from the sidebar to the right edge of the workspace + // zone to create a SPLIT (side-by-side) tile. This triggers the real + // startSessionDrag → onCommit → openSessionTile(id, 'right', anchor) path. + const row = sessionRow(page, SIDEBAR_CROSS_TEXTS.finalText) + const rowBox = await row.boundingBox() + expect(rowBox, 'session row must be visible').not.toBeNull() + + // Find the workspace zone — the main chat area. We drop on its right edge. + const workspace = page.locator('[data-session-anchor="workspace"]') + const wsBox = await workspace.boundingBox() + expect(wsBox, 'workspace zone must be visible').not.toBeNull() + + // Drag from the session row to the right edge of the workspace. + // The drag-session's subZonePosition resolves a right-edge drop as 'right' + // (a split dock), not 'center' (which would be a composer link). + await page.mouse.move(rowBox!.x + rowBox!.width / 2, rowBox!.y + rowBox!.height / 2) + await page.mouse.down() + // Move in steps so the drag-session's pointermove handler tracks the + // position and resolves the drop zone (a single jump can miss the + // threshold/engage logic). + const targetX = wsBox!.x + wsBox!.width - 20 + const targetY = wsBox!.y + wsBox!.height / 2 + const steps = 10 + for (let i = 1; i <= steps; i++) { + const x = rowBox!.x + rowBox!.width / 2 + (targetX - (rowBox!.x + rowBox!.width / 2)) * (i / steps) + const y = rowBox!.y + rowBox!.height / 2 + (targetY - (rowBox!.y + rowBox!.height / 2)) * (i / steps) + await page.mouse.move(x, y) + await page.waitForTimeout(30) + } + await page.mouse.up() + await page.waitForTimeout(2000) + + // Evidence: the split tile is now open side-by-side — both sessions visible. + await page.screenshot({ path: 'test-results/tile-bug-split-opened.png' }) + + await waitForBgProcessToFinish(page) + + // THE BUG: the session visible in the split tile should NOT have the green + // "finished unread" dot — the user is looking right at it. This assertion + // FAILS until the fix in session-states.ts:174 lands. + const unreadCount = await page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count() + expect(unreadCount, 'session visible in a split tile should NOT be marked unread').toBe(0) + + // Evidence: the green dot should NOT be here — this screenshot shows the bug. + await page.screenshot({ path: 'test-results/tile-bug-split-unread-should-not-exist.png' }) + }) +}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cb8cdcf76429..643126546507 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -55,9 +55,9 @@ "test": "vitest run", "preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174", "check": "npm run typecheck && npm run test && npm run test:desktop:all", - "test:e2e": "npm run clean:e2e && playwright test e2e/", - "test:e2e:visual": "npm run clean:e2e && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- playwright test e2e/ --reporter=list", - "test:e2e:update-snapshots": "npm run clean:e2e && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- playwright test e2e/ --reporter=list --update-snapshots" + "test:e2e": "npm run build && playwright test e2e/", + "test:e2e:visual": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list", + "test:e2e:update-snapshots": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list --update-snapshots" }, "dependencies": { "@assistant-ui/react": "^0.14.23", diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx new file mode 100644 index 000000000000..3bf8ae8617ae --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx @@ -0,0 +1,138 @@ +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { ComposerAttachment } from '@/store/composer' + +import { useComposerSubmit } from './use-composer-submit' + +interface SubmitHarnessOptions { + attachments?: ComposerAttachment[] + busy?: boolean + text?: string +} + +function renderSubmitHook({ attachments = [], busy = false, text = '' }: SubmitHarnessOptions = {}) { + const draftRef = { current: text } + const editor = document.createElement('div') + editor.dataset.slot = 'composer-rich-input' + editor.textContent = text + const editorRef = { current: editor } + const onCancel = vi.fn() + const onSteer = vi.fn(async () => true) + const onSubmit = vi.fn(async () => true) + const queueCurrentDraft = vi.fn(() => true) + const clearDraft = vi.fn(() => { + draftRef.current = '' + editorRef.current!.textContent = '' + }) + + const hook = renderHook(() => + useComposerSubmit({ + activeQueueSessionKey: 'stored-session', + activeQueueSessionKeyRef: { current: 'stored-session' }, + attachments, + busy, + clearDraft, + disabled: false, + draftRef, + drainNextQueued: vi.fn(async () => false), + editorRef, + exitQueuedEdit: vi.fn(() => false), + focusInput: vi.fn(), + inputDisabled: false, + loadIntoComposer: vi.fn(), + onCancel, + onSteer, + onSubmit, + queueCurrentDraft, + queueEdit: null, + queuedPrompts: [], + sessionId: 'runtime-session', + setComposerText: vi.fn(), + stashAt: vi.fn() + }) + ) + + return { clearDraft, hook, onCancel, onSteer, onSubmit, queueCurrentDraft } +} + +describe('useComposerSubmit busy-turn routing', () => { + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('steers a plain-text follow-up instead of queueing or stopping', async () => { + const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({ busy: true, text: 'change course' }) + + act(() => { + hook.result.current.submitDraft() + }) + + await waitFor(() => expect(onSteer).toHaveBeenCalledWith('change course')) + expect(queueCurrentDraft).not.toHaveBeenCalled() + expect(onCancel).not.toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('runs slash commands immediately while busy', async () => { + const { clearDraft, hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({ + busy: true, + text: '/compress preserve context' + }) + + act(() => { + hook.result.current.submitDraft() + }) + + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('/compress preserve context')) + expect(clearDraft).toHaveBeenCalledTimes(1) + expect(onSteer).not.toHaveBeenCalled() + expect(queueCurrentDraft).not.toHaveBeenCalled() + expect(onCancel).not.toHaveBeenCalled() + }) + + it('queues an attachment-bearing follow-up while busy', () => { + const attachment: ComposerAttachment = { id: 'doc', kind: 'file', label: 'notes.txt' } + const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({ + attachments: [attachment], + busy: true, + text: 'read this' + }) + + act(() => { + hook.result.current.submitDraft() + }) + + expect(queueCurrentDraft).toHaveBeenCalledTimes(1) + expect(onSteer).not.toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + expect(onCancel).not.toHaveBeenCalled() + }) + + it('stops an active turn only with an empty composer', () => { + const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({ busy: true }) + + act(() => { + hook.result.current.submitDraft() + }) + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(onSteer).not.toHaveBeenCalled() + expect(onSubmit).not.toHaveBeenCalled() + expect(queueCurrentDraft).not.toHaveBeenCalled() + }) + + it('submits a normal turn while idle', async () => { + const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({ text: 'ordinary question' }) + + act(() => { + hook.result.current.submitDraft() + }) + + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('ordinary question', { attachments: [] })) + expect(onSteer).not.toHaveBeenCalled() + expect(queueCurrentDraft).not.toHaveBeenCalled() + expect(onCancel).not.toHaveBeenCalled() + }) +}) From 54aaff142e299b3cd4d8a47bbf3d861e34681b1c Mon Sep 17 00:00:00 2001 From: "hermes-seaeye[bot]" <307254004+hermes-seaeye[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:05:44 +0000 Subject: [PATCH 058/238] fmt(js): `npm run fix` on merge (#69669) Co-authored-by: github-actions[bot] --- .../app/chat/composer/hooks/use-composer-submit.test.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx index 3bf8ae8617ae..c2a6bc6d7993 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-submit.test.tsx @@ -21,6 +21,7 @@ function renderSubmitHook({ attachments = [], busy = false, text = '' }: SubmitH const onSteer = vi.fn(async () => true) const onSubmit = vi.fn(async () => true) const queueCurrentDraft = vi.fn(() => true) + const clearDraft = vi.fn(() => { draftRef.current = '' editorRef.current!.textContent = '' @@ -63,7 +64,10 @@ describe('useComposerSubmit busy-turn routing', () => { }) it('steers a plain-text follow-up instead of queueing or stopping', async () => { - const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({ busy: true, text: 'change course' }) + const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({ + busy: true, + text: 'change course' + }) act(() => { hook.result.current.submitDraft() @@ -94,6 +98,7 @@ describe('useComposerSubmit busy-turn routing', () => { it('queues an attachment-bearing follow-up while busy', () => { const attachment: ComposerAttachment = { id: 'doc', kind: 'file', label: 'notes.txt' } + const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({ attachments: [attachment], busy: true, From 960d339f86f1b2bd25c324a8c740784d6d22aa55 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 18:08:59 -0500 Subject: [PATCH 059/238] feat(billing): shared cross-surface out-of-credits signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect a billing wall once (agent/error_classifier → FailoverReason.billing) and map it to a recovery link + label in one place, then carry that structured BillingBlock to every surface instead of re-parsing free-form error text per surface. - agent/billing_links.py: provider-agnostic slug/host → (label, billing URL) table (single source of truth), Nous-aware (is_nous routes to the in-app flow); unknown providers degrade to a readable label with no invented URL. - conversation_loop: both billing exit paths return a billing_block through one helper; the guidance message carries the derived URL for every provider. - gateway forwards billing_block on message.complete (it was dropped). - @hermes/shared: BillingBlock type shared by desktop + TUI. --- agent/billing_links.py | 124 ++++++++++++++++++++++++++++++ agent/conversation_loop.py | 61 ++++++++++++++- apps/shared/src/billing-types.ts | 23 ++++++ apps/shared/src/index.ts | 1 + tests/agent/test_billing_links.py | 103 +++++++++++++++++++++++++ tui_gateway/server.py | 7 ++ 6 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 agent/billing_links.py create mode 100644 tests/agent/test_billing_links.py diff --git a/agent/billing_links.py b/agent/billing_links.py new file mode 100644 index 000000000000..1e9320ebb45a --- /dev/null +++ b/agent/billing_links.py @@ -0,0 +1,124 @@ +"""Provider-agnostic billing/credit recovery links. + +Maps a billing-classified failure onto a recovery link + label. *Detection* +is not done here — that is :mod:`agent.error_classifier` +(``FailoverReason.billing``), the single source of truth for "credit wall vs. +rate limit / auth / transport". The resulting :class:`BillingBlock` rides the +turn result and the gateway ``message.complete`` event so every surface (CLI, +TUI, desktop) renders one structured signal instead of re-parsing error text. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Optional + +from utils import base_url_host_matches + + +@dataclass +class BillingBlock: + """Structured billing-wall descriptor shared across every surface. + + ``is_nous`` is the routing bit: Nous has a first-class in-app billing surface + (desktop Settings → Billing, TUI/CLI ``/topup``), so surfaces prefer that over + ``billing_url``; third-party providers have no in-app flow, so ``billing_url`` + is the deep link the user actually needs. + """ + + provider: str + provider_label: str + model: str + billing_url: Optional[str] + is_nous: bool + message: str + + def to_dict(self) -> dict: + return asdict(self) + + +@dataclass(frozen=True) +class _Provider: + label: str + url: str + slugs: tuple[str, ...] + hosts: tuple[str, ...] = () + + +# Single source of truth: internal slug(s) + base_url host(s) → billing page. +# Curated "add credits / manage billing" landing pages, not marketing homes. +# Hosts back the OpenAI-compatible fallback where the slug is a generic bucket +# (e.g. "openai_compatible") but base_url reveals the real upstream. An unknown +# provider degrades to a readable label with no invented URL. +_PROVIDERS: tuple[_Provider, ...] = ( + _Provider("OpenAI", "https://platform.openai.com/settings/organization/billing", ("openai",), ("api.openai.com",)), + _Provider("Anthropic", "https://console.anthropic.com/settings/billing", ("anthropic",), ("api.anthropic.com",)), + _Provider("OpenRouter", "https://openrouter.ai/settings/credits", ("openrouter",), ("openrouter.ai",)), + _Provider("xAI", "https://console.x.ai/team/default/billing", ("xai", "xai-oauth"), ("api.x.ai",)), + _Provider("DeepSeek", "https://platform.deepseek.com/top_up", ("deepseek",), ("api.deepseek.com",)), + _Provider("Groq", "https://console.groq.com/settings/billing", ("groq",), ("api.groq.com",)), + _Provider("Mistral", "https://console.mistral.ai/billing", ("mistral",), ("api.mistral.ai",)), + _Provider("Together AI", "https://api.together.ai/settings/billing", ("together",), ("api.together.ai", "api.together.xyz")), + _Provider("Fireworks AI", "https://fireworks.ai/account/billing", ("fireworks",), ("fireworks.ai",)), + _Provider("Perplexity", "https://www.perplexity.ai/settings/api", ("perplexity",), ("perplexity.ai",)), + _Provider("Google AI", "https://aistudio.google.com/app/billing", ("google", "gemini"), ("generativelanguage.googleapis.com",)), + _Provider("Cohere", "https://dashboard.cohere.com/billing", ("cohere",)), + _Provider("Moonshot AI", "https://platform.moonshot.ai/console/pay", ("moonshot",)), + _Provider("NVIDIA", "https://build.nvidia.com/settings/billing", ("nvidia",)), +) + +_BY_SLUG: dict[str, _Provider] = {slug: p for p in _PROVIDERS for slug in p.slugs} + + +def is_nous_inference_route(provider: str, base_url: str) -> bool: + """True when the failing route is the Nous-managed inference gateway.""" + if (provider or "").strip().lower() == "nous": + return True + return base_url_host_matches(str(base_url or ""), "inference-api.nousresearch.com") + + +def _nous_billing_url() -> Optional[str]: + """Best-effort Nous portal billing URL (text-surface fallback; Nous prefers the in-app flow).""" + try: + from hermes_cli.nous_account import nous_portal_billing_url + + return nous_portal_billing_url(None) + except Exception: + return "https://portal.nousresearch.com/billing" + + +def _resolve_provider_link(slug: str, base_url: str) -> tuple[str, Optional[str]]: + """Resolve ``(label, url)``: exact slug → base_url host → readable-label fallback.""" + hit = _BY_SLUG.get(slug) + if hit: + return hit.label, hit.url + + base = str(base_url or "") + for p in _PROVIDERS: + if any(base_url_host_matches(base, host) for host in p.hosts): + return p.label, p.url + + return slug.replace("_", " ").replace("-", " ").strip().title() or "your provider", None + + +def build_billing_block( + *, + provider: str, + base_url: str, + model: str, + message: str = "", +) -> BillingBlock: + """Build the billing descriptor for a billing-classified failure. + + ``message`` is the guidance already assembled by the agent loop + (:func:`agent.conversation_loop._billing_or_entitlement_message`), carried + through unchanged so every surface shows identical copy. + """ + slug = (provider or "").strip().lower() + model = (model or "").strip() + + if is_nous_inference_route(slug, base_url): + return BillingBlock(slug or "nous", "Nous Portal", model, _nous_billing_url(), True, message or "") + + label, url = _resolve_provider_link(slug, base_url) + return BillingBlock(slug, label, model, url, False, message or "") diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index fb9d11b66fce..22b1a6c213b8 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -300,6 +300,19 @@ def _billing_or_entitlement_message( ] return "\n".join(lines) + # Provider-agnostic billing URL derivation (OpenAI, DeepSeek, xAI, Groq, + # OpenRouter, …) so every text surface — CLI, gateway messaging, TUI + # transcript — shows the same actionable link, not just OpenRouter. + try: + from agent.billing_links import build_billing_block + + _link = build_billing_block(provider=provider, base_url=base_url, model=model) + if _link.provider_label: + provider_label = _link.provider_label + billing_url = _link.billing_url + except Exception: + billing_url = None + lines = [ ( f"{provider_label} reported that billing, credits, or account " @@ -307,12 +320,24 @@ def _billing_or_entitlement_message( ), "Add credits or update billing with that provider, then retry.", ] - if base_url_host_matches(str(base_url or ""), "openrouter.ai"): - lines.append("OpenRouter credits: https://openrouter.ai/settings/credits") + if billing_url: + lines.append(f"{provider_label} billing: {billing_url}") lines.append("You can switch providers temporarily with /model --provider .") return "\n".join(lines) +def _billing_block_dict(provider, base_url, model, message="") -> Optional[dict]: + """Best-effort structured billing descriptor (None if billing_links is unavailable).""" + try: + from agent.billing_links import build_billing_block + + return build_billing_block( + provider=provider, base_url=str(base_url), model=model, message=message + ).to_dict() + except Exception: + return None + + def _print_billing_or_entitlement_guidance( agent, *, @@ -4275,6 +4300,31 @@ def run_conversation( final_response=_policy_response, error_detail=_nonretryable_summary, ) + # Billing walls are the common non-retryable abort: enrich + # the result with the same structured recovery descriptor as + # the max-retries path so every surface (CLI, TUI, desktop) + # renders one consistent billing signal. + if classified.reason == FailoverReason.billing: + _ce_guidance = _billing_or_entitlement_message( + capability="model access", + provider=_provider, + base_url=str(_base), + model=_model, + ) + _ce_final = f"Billing or credits exhausted: {_nonretryable_summary}" + if _ce_guidance: + _ce_final += f"\n\n{_ce_guidance}" + _ce_block = _billing_block_dict(_provider, _base, _model, _ce_guidance) + return { + "final_response": _ce_final, + "messages": messages, + "api_calls": api_call_count, + "completed": False, + "failed": True, + "error": _nonretryable_summary, + "failure_reason": classified.reason.value, + "billing_block": _ce_block, + } return { "final_response": _nonretryable_summary, "messages": messages, @@ -4435,10 +4485,14 @@ def run_conversation( api_kwargs, reason="max_retries_exhausted", error=api_error, ) agent._persist_session(messages, conversation_history) + _billing_block = None if classified.reason == FailoverReason.billing: _final_response = f"Billing or credits exhausted: {_final_summary}" if _billing_guidance: _final_response += f"\n\n{_billing_guidance}" + # Structured recovery descriptor so every surface renders + # the same link + label from one signal (see helper). + _billing_block = _billing_block_dict(_provider, _base, _model, _billing_guidance) else: _final_response = f"API call failed after {max_retries} retries: {_final_summary}" if _is_thinking_timeout: @@ -4478,6 +4532,9 @@ def run_conversation( # different exit code. ``rate_limit`` / ``billing`` here # mean "quota wall, not a task error". "failure_reason": classified.reason.value, + # Present only for billing walls: structured recovery + # descriptor (provider, billing_url, is_nous, message). + "billing_block": _billing_block, } # For rate limits, respect the Retry-After header if present diff --git a/apps/shared/src/billing-types.ts b/apps/shared/src/billing-types.ts index d8d7415c7bb3..9a7801c22334 100644 --- a/apps/shared/src/billing-types.ts +++ b/apps/shared/src/billing-types.ts @@ -6,6 +6,29 @@ * gateway event union out of this runtime-free module. */ +// ── Billing wall (inference credit exhaustion) ─────────────────────── + +/** + * Structured billing-wall descriptor emitted by the gateway on the + * `message.complete` event (`payload.billing`) when an inference call fails + * because the account is out of credits / payment is required — mirrors the + * Python `agent/billing_links.py::BillingBlock`. + * + * Detection is backend-only (`agent/error_classifier.py` → + * `FailoverReason.billing`), so every surface renders from this one signal and + * never re-classifies free-form error text. `is_nous` routes recovery: Nous is + * the managed route with in-app billing (desktop Settings → Billing, TUI + * `/topup`), while third-party providers deep-link to `billing_url`. + */ +export interface BillingBlock { + provider: string + provider_label: string + model: string + billing_url: string | null + is_nous: boolean + message: string +} + // ── Remote Spending (Phase 2b) ─────────────────────────────────────── /** One serialized usage bar (mirrors server `_serialize_usage_bar`). */ diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts index b951bc4700e9..09e10e40052e 100644 --- a/apps/shared/src/index.ts +++ b/apps/shared/src/index.ts @@ -6,6 +6,7 @@ export { } from './billing-policy' export type { BillingAutoReload, + BillingBlock, BillingCardInfo, BillingChargeResponse, BillingChargeStatusResponse, diff --git a/tests/agent/test_billing_links.py b/tests/agent/test_billing_links.py new file mode 100644 index 000000000000..2c17853ea59c --- /dev/null +++ b/tests/agent/test_billing_links.py @@ -0,0 +1,103 @@ +"""Tests for provider-agnostic billing recovery links (agent/billing_links.py). + +Behavior/invariant tests — no snapshotting of the exact URL strings beyond the +few that are the whole point of the mapping (the host they must land on). +""" + +from __future__ import annotations + +from agent.billing_links import ( + BillingBlock, + build_billing_block, + is_nous_inference_route, +) + + +def test_nous_route_by_provider_slug(): + block = build_billing_block(provider="nous", base_url="", model="hermes-4") + assert block.is_nous is True + assert block.provider_label == "Nous Portal" + # Nous always resolves an in-app/portal billing URL as a fallback. + assert block.billing_url and "nousresearch.com" in block.billing_url + + +def test_nous_route_by_base_url_host(): + block = build_billing_block( + provider="openai_compatible", + base_url="https://inference-api.nousresearch.com/v1", + model="hermes-4", + ) + assert block.is_nous is True + + +def test_is_nous_inference_route_helper(): + assert is_nous_inference_route("nous", "") is True + assert is_nous_inference_route("", "https://inference-api.nousresearch.com/v1") is True + assert is_nous_inference_route("openai", "https://api.openai.com/v1") is False + + +def test_known_provider_by_slug_resolves_label_and_url(): + block = build_billing_block(provider="openai", base_url="", model="gpt-5") + assert block.is_nous is False + assert block.provider_label == "OpenAI" + assert block.billing_url is not None + assert "openai.com" in block.billing_url + + +def test_openrouter_resolves_credits_page(): + block = build_billing_block( + provider="openrouter", + base_url="https://openrouter.ai/api/v1", + model="anthropic/claude", + ) + assert block.is_nous is False + assert block.billing_url is not None + assert "openrouter.ai" in block.billing_url + + +def test_unknown_provider_via_base_url_host_fallback(): + # Provider slug is a generic bucket; the host reveals the real upstream. + block = build_billing_block( + provider="custom", + base_url="https://api.deepseek.com/v1", + model="deepseek-chat", + ) + assert block.provider_label == "DeepSeek" + assert block.billing_url is not None + assert "deepseek.com" in block.billing_url + + +def test_unknown_provider_degrades_without_url(): + block = build_billing_block( + provider="my_local_llm", + base_url="http://localhost:1234/v1", + model="llama", + ) + assert block.is_nous is False + # No invented URL for an unknown provider — but a readable label survives. + assert block.billing_url is None + assert block.provider_label # non-empty, humanized + + +def test_message_is_carried_through_unchanged(): + block = build_billing_block( + provider="openai", + base_url="", + model="gpt-5", + message="You are out of credits.", + ) + assert block.message == "You are out of credits." + + +def test_to_dict_round_trips_all_fields(): + block = build_billing_block(provider="openai", base_url="", model="gpt-5") + data = block.to_dict() + assert set(data) == { + "provider", + "provider_label", + "model", + "billing_url", + "is_nous", + "message", + } + assert isinstance(block, BillingBlock) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 2e624336ba14..285258acb935 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -10608,6 +10608,13 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None: payload["warning"] = status_note if result.get("response_previewed"): payload["response_previewed"] = True + # Forward the structured billing-wall descriptor (provider, + # billing_url, is_nous, message) so the TUI/desktop render a + # billing-specific recovery surface instead of re-parsing text. + _billing_block = result.get("billing_block") if isinstance(result, dict) else None + if _billing_block: + payload["billing"] = _billing_block + payload["failure_reason"] = result.get("failure_reason") rendered = render_message(raw, cols) if rendered: payload["rendered"] = rendered From d0c4a82da97cf1d8605f1bb20f9ab74141be7373 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 18:09:08 -0500 Subject: [PATCH 060/238] feat(desktop): billing toast + in-chat status-row banner with smart CTA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a billing wall, raise a sticky, billing-specific toast (never the generic error toast) and a persistent in-composer banner for the active session — both with one recovery action: Nous → in-app Settings → Billing, other providers → their billing page (deep-linked). The banner reuses the shared StatusRow chrome (no bordered alert, Codicon glyph, shared buttons), and the composer stays usable so slash commands keep working. --- .../app/chat/composer/status-stack/index.tsx | 10 +++ apps/desktop/src/app/contrib/wiring.tsx | 18 ++++ .../hooks/use-message-stream/gateway-event.ts | 55 ++++++++++++ .../desktop/src/components/billing-banner.tsx | 72 ++++++++++++++++ apps/desktop/src/i18n/en.ts | 9 ++ apps/desktop/src/i18n/ja.ts | 9 ++ apps/desktop/src/i18n/types.ts | 9 ++ apps/desktop/src/i18n/zh-hant.ts | 9 ++ apps/desktop/src/i18n/zh.ts | 9 ++ apps/desktop/src/lib/chat-messages.ts | 5 ++ apps/desktop/src/store/billing-block.test.ts | 86 +++++++++++++++++++ apps/desktop/src/store/billing-block.ts | 79 +++++++++++++++++ 12 files changed, 370 insertions(+) create mode 100644 apps/desktop/src/components/billing-banner.tsx create mode 100644 apps/desktop/src/store/billing-block.test.ts create mode 100644 apps/desktop/src/store/billing-block.ts diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx index e8107764d881..739080be8b85 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom' import { blurComposerInput } from '@/app/chat/composer/focus' import { AGENTS_ROUTE } from '@/app/routes' +import { BillingBanner } from '@/components/billing-banner' import { composerDockCard } from '@/components/chat/composer-dock' import { StatusSection } from '@/components/chat/status-section' import { Button } from '@/components/ui/button' @@ -11,6 +12,7 @@ import { Codicon } from '@/components/ui/codicon' import { Tip, TipKeybindLabel } from '@/components/ui/tooltip' import { type Translations, useI18n } from '@/i18n' import { cn } from '@/lib/utils' +import { $billingBlock } from '@/store/billing-block' import { $statusItemsBySession, type ComposerStatusItem, @@ -70,6 +72,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro const itemsBySession = useStore($statusItemsBySession) const previewsBySession = useStore($previewStatusBySession) const scrolledUp = useStore($threadScrolledUp) + const billing = useStore($billingBlock) const groups = useMemo( () => groupStatusItems(sessionId ? (itemsBySession[sessionId] ?? []) : []), @@ -123,6 +126,13 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro const sections: { key: string; node: ReactNode }[] = [] + // Billing wall sits at the very top of the stack — it's the most important + // thing above the composer when the account is out of credits. Rendered here + // (not as a composer-disable) so slash commands stay usable. + if (billing && sessionId && billing.sessionId === sessionId) { + sections.push({ key: 'billing', node: }) + } + for (const group of groups) { sections.push({ key: group.type, diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index b2d0c03a9881..6c36e452e6e6 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -27,6 +27,7 @@ import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChat import { sessionMessagesSignature } from '@/lib/session-signatures' import { isMessagingSource } from '@/lib/session-source' import { latestSessionTodos } from '@/lib/todos' +import { $billingSettingsRequest } from '@/store/billing-block' import { setCronFocusJobId } from '@/store/cron' import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout' import { $filePreviewTarget, $previewTarget } from '@/store/preview' @@ -126,6 +127,10 @@ export function ContribWiring({ children }: { children: ReactNode }) { const busyRef = useRef(false) const creatingSessionRef = useRef(false) + // Billing recovery routes to Settings → Billing from surfaces without router + // context (the sticky toast). The shell owns `navigate`, so it consumes the + // intent counter here; the ref skips the initial mount value. + const billingSettingsSeenRef = useRef(0) const messagingTranscriptSignatureRef = useRef(new Map()) // Stable identity for the whole callback surface (see WiringActions). Mutated // in place each render so memoized surfaces never re-render on churn. @@ -133,7 +138,20 @@ export function ContribWiring({ children }: { children: ReactNode }) { const gatewayState = useStore($gatewayState) const activeSessionId = useStore($activeSessionId) + const billingSettingsRequest = useStore($billingSettingsRequest) const currentCwd = useStore($currentCwd) + + useEffect(() => { + if (billingSettingsRequest === billingSettingsSeenRef.current) { + return + } + + billingSettingsSeenRef.current = billingSettingsRequest + + if (billingSettingsRequest > 0) { + navigate(`${SETTINGS_ROUTE}?tab=billing`) + } + }, [billingSettingsRequest, navigate]) const freshDraftReady = useStore($freshDraftReady) const resumeFailedSessionId = useStore($resumeFailedSessionId) const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 5b8fccd8bb00..7cc7d2a9f924 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -1,3 +1,4 @@ +import type { BillingBlock } from '@hermes/shared' import type { HermesSkin } from '@hermes/shared/skin' import type { QueryClient } from '@tanstack/react-query' import { type MutableRefObject, useCallback, useEffect, useRef } from 'react' @@ -15,6 +16,7 @@ import { triggerHaptic } from '@/lib/haptics' import { modelOptionsQueryKey } from '@/lib/model-options' import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' import { reconcileApprovalModeForProfile } from '@/store/approval-mode' +import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block' import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify' import { setSessionCompacting } from '@/store/compaction' import { refreshBackgroundProcesses } from '@/store/composer-status' @@ -57,6 +59,50 @@ import type { ClientSessionState } from '../../../types' import { hasSessionInfoStatePatch, sessionInfoStatePatch, SUBAGENT_EVENT_TYPES, toTodoPayload } from './utils' +function firstBillingLine(text: string): string { + return (text || '').split('\n')[0]?.trim() ?? '' +} + +/** + * A turn failed on a billing wall (out of credits / payment required). The + * gateway forwards the structured descriptor built by `agent/billing_links.py`; + * we cache it per-session (drives the in-chat banner) AND raise one sticky, + * billing-specific toast — never the generic "Hermes error" — with a smart CTA + * (Nous → in-app Settings → Billing, other providers → their billing page). + */ +function surfaceBillingBlock(sessionId: string, raw: unknown): void { + if (!raw || typeof raw !== 'object') { + return + } + + const block = raw as BillingBlock + + if (typeof block.provider !== 'string') { + return + } + + setBillingBlock(sessionId, block) + + const ctaCopy = { + addCredits: translateNow('billingBlock.addCredits'), + openBilling: translateNow('billingBlock.openBilling') + } + + notify({ + // Collapse repeat walls from the same provider into one toast. + id: `billing-block:${block.provider}`, + kind: 'warning', + icon: 'credit-card', + title: block.is_nous + ? translateNow('billingBlock.titleNous') + : translateNow('billingBlock.titleProvider', block.provider_label), + message: firstBillingLine(block.message) || translateNow('billingBlock.fallbackMessage'), + // Sticky: a credit wall blocks every turn until resolved. + durationMs: 0, + action: { label: billingCtaLabel(block, ctaCopy), onClick: () => runBillingRecovery(block) } + }) +} + const COMPACTION_RESUME_EVENT_TYPES = new Set([ 'message.delta', 'message.interim', @@ -390,6 +436,9 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { setSessionCompacting(sessionId, false) compactedTurnRef.current.delete(sessionId) nativeSubagentSessionsRef.current.delete(sessionId) + // A fresh turn on this session optimistically clears its billing wall; + // if credits are still exhausted the next failure re-raises it. + clearBillingBlock(sessionId) if (isActiveEvent) { triggerHaptic('streamStart') @@ -513,6 +562,12 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered) completeAssistantMessage(sessionId, finalText, payload?.response_previewed) + // Structured billing wall forwarded by the gateway (out of credits / + // payment required) — cache it + raise a billing-specific toast. + if (payload?.billing) { + surfaceBillingBlock(sessionId, payload.billing) + } + if (isActiveEvent) { setTurnStartedAt(null) diff --git a/apps/desktop/src/components/billing-banner.tsx b/apps/desktop/src/components/billing-banner.tsx new file mode 100644 index 000000000000..73f7107decbf --- /dev/null +++ b/apps/desktop/src/components/billing-banner.tsx @@ -0,0 +1,72 @@ +import { useStore } from '@nanostores/react' + +import { StatusRow } from '@/components/chat/status-row' +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Tip } from '@/components/ui/tooltip' +import { useI18n } from '@/i18n' +import { $billingBlock, billingCtaLabel, clearBillingBlock, runBillingRecovery } from '@/store/billing-block' + +function firstLine(text: string): string { + return (text || '').split('\n')[0]?.trim() ?? '' +} + +/** + * Persistent, in-stack billing wall for THIS session. Rendered as a shared + * {@link StatusRow} — same chrome as its status-stack siblings, so it reads as + * one piece with the composer card (no bordered alert-in-a-card). It never + * disables the composer — slash commands (`/topup`, `/model`, `/login`) stay + * usable — it only offers recovery: Nous opens Settings → Billing in-app, other + * providers deep-link out. The sticky toast is the loud surface; this is the calm + * reminder that outlives it. + */ +export function BillingBanner({ sessionId }: { sessionId: null | string }) { + const active = useStore($billingBlock) + const { t } = useI18n() + + if (!active || !sessionId || active.sessionId !== sessionId) { + return null + } + + const { block } = active + const copy = t.billingBlock + const title = block.is_nous ? copy.titleNous : copy.titleProvider(block.provider_label) + const message = firstLine(block.message) || copy.fallbackMessage + + return ( + } + trailing={ + <> + + + + + + } + trailingVisible + > + + {title} + {message && · {message}} + + + ) +} diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 79351a815174..53802fcd8cdb 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -177,6 +177,15 @@ export const en: Translations = { `Software rendering active — remote display detected (${reason}). GPU acceleration is disabled to prevent flickering.` }, + billingBlock: { + titleNous: 'Out of Nous credits', + titleProvider: provider => `Out of credits — ${provider}`, + fallbackMessage: 'Your account is out of credits. Add credits to keep going.', + openBilling: 'Open billing', + addCredits: 'Add credits', + dismiss: 'Dismiss' + }, + titlebar: { hideSidebar: 'Hide sidebar', showSidebar: 'Show sidebar', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index afa02b44bf8b..c712198e6132 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -178,6 +178,15 @@ export const ja = defineLocale({ `ソフトウェアレンダリングが有効です — リモートディスプレイを検出しました(${reason})。ちらつきを防ぐため GPU アクセラレーションは無効化されています。` }, + billingBlock: { + titleNous: 'Nous クレジットが不足しています', + titleProvider: provider => `クレジット不足 — ${provider}`, + fallbackMessage: 'アカウントのクレジットが不足しています。続行するにはクレジットを追加してください。', + openBilling: '請求を開く', + addCredits: 'クレジットを追加', + dismiss: '閉じる' + }, + titlebar: { hideSidebar: 'サイドバーを非表示', showSidebar: 'サイドバーを表示', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 4ba7fc84c38c..18f96afb90d1 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -218,6 +218,15 @@ export interface Translations { message: (reason: string) => string } + billingBlock: { + titleNous: string + titleProvider: (provider: string) => string + fallbackMessage: string + openBilling: string + addCredits: string + dismiss: string + } + titlebar: { hideSidebar: string showSidebar: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index e3c382a8ef59..c19c10bb4769 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -172,6 +172,15 @@ export const zhHant = defineLocale({ message: reason => `軟體繪圖已啟用 — 偵測到遠端顯示(${reason})。為防止畫面閃爍,已停用 GPU 加速。` }, + billingBlock: { + titleNous: 'Nous 額度已用盡', + titleProvider: provider => `額度已用盡 — ${provider}`, + fallbackMessage: '您的帳戶額度已用盡。請儲值以繼續使用。', + openBilling: '開啟帳單', + addCredits: '新增額度', + dismiss: '忽略' + }, + titlebar: { hideSidebar: '隱藏側邊欄', showSidebar: '顯示側邊欄', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 578a6faa7409..8515ec15f429 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -172,6 +172,15 @@ export const zh: Translations = { message: reason => `软件渲染已启用 — 检测到远程显示(${reason})。为防止画面闪烁,已禁用 GPU 加速。` }, + billingBlock: { + titleNous: 'Nous 额度已用尽', + titleProvider: provider => `额度已用尽 — ${provider}`, + fallbackMessage: '您的账户额度已用尽。请充值以继续使用。', + openBilling: '打开账单', + addCredits: '添加额度', + dismiss: '忽略' + }, + titlebar: { hideSidebar: '隐藏侧边栏', showSidebar: '显示侧边栏', diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index 74beafcffd60..828021b21eaa 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -1,4 +1,5 @@ import type { ThreadMessageLike } from '@assistant-ui/react' +import type { BillingBlock } from '@hermes/shared' import { dedupeGeneratedImageEchoesInParts } from '@/lib/generated-images' import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media' @@ -96,6 +97,10 @@ export type GatewayEventPayload = { // message.complete — signals the final text was already previewed via // interim_assistant_callback, so the UI can settle instead of duplicating. response_previewed?: boolean + // Structured billing wall forwarded on message.complete when a turn fails + // with FailoverReason.billing (shape mirrors @hermes/shared BillingBlock). + billing?: BillingBlock + failure_reason?: string } export function textPart(text: string): ChatMessagePart { diff --git a/apps/desktop/src/store/billing-block.test.ts b/apps/desktop/src/store/billing-block.test.ts new file mode 100644 index 000000000000..24d2f97402f1 --- /dev/null +++ b/apps/desktop/src/store/billing-block.test.ts @@ -0,0 +1,86 @@ +import type { BillingBlock } from '@hermes/shared' +import { beforeEach, expect, test, vi } from 'vitest' + +vi.mock('@/lib/external-link', () => ({ openExternalLink: vi.fn() })) + +import { openExternalLink } from '@/lib/external-link' + +import { + $billingBlock, + $billingSettingsRequest, + billingCtaLabel, + clearBillingBlock, + requestBillingSettings, + runBillingRecovery, + setBillingBlock +} from './billing-block' + +function makeBlock(overrides: Partial = {}): BillingBlock { + return { + billing_url: 'https://platform.openai.com/settings/organization/billing', + is_nous: false, + message: 'You are out of credits.', + model: 'gpt-5', + provider: 'openai', + provider_label: 'OpenAI', + ...overrides + } +} + +beforeEach(() => { + $billingBlock.set(null) + $billingSettingsRequest.set(0) + vi.clearAllMocks() +}) + +test('setBillingBlock stores the block against its session', () => { + setBillingBlock('s1', makeBlock()) + expect($billingBlock.get()?.sessionId).toBe('s1') + expect($billingBlock.get()?.block.provider).toBe('openai') +}) + +test('clearBillingBlock scoped to a session leaves a different session block intact', () => { + setBillingBlock('s1', makeBlock()) + clearBillingBlock('s2') + expect($billingBlock.get()).not.toBeNull() + + clearBillingBlock('s1') + expect($billingBlock.get()).toBeNull() +}) + +test('clearBillingBlock with no arg clears any active block', () => { + setBillingBlock('s1', makeBlock()) + clearBillingBlock() + expect($billingBlock.get()).toBeNull() +}) + +test('runBillingRecovery routes Nous to in-app Settings, never an external link', () => { + runBillingRecovery(makeBlock({ is_nous: true, provider: 'nous', provider_label: 'Nous Portal' })) + expect($billingSettingsRequest.get()).toBe(1) + expect(openExternalLink).not.toHaveBeenCalled() +}) + +test('runBillingRecovery deep-links a third-party provider to its billing page', () => { + const block = makeBlock({ billing_url: 'https://openrouter.ai/settings/credits', provider: 'openrouter' }) + runBillingRecovery(block) + expect(openExternalLink).toHaveBeenCalledWith('https://openrouter.ai/settings/credits') + expect($billingSettingsRequest.get()).toBe(0) +}) + +test('runBillingRecovery falls back to in-app settings when a provider has no URL', () => { + runBillingRecovery(makeBlock({ billing_url: null, provider: 'custom' })) + expect(openExternalLink).not.toHaveBeenCalled() + expect($billingSettingsRequest.get()).toBe(1) +}) + +test('requestBillingSettings increments the intent counter', () => { + requestBillingSettings() + requestBillingSettings() + expect($billingSettingsRequest.get()).toBe(2) +}) + +test('billingCtaLabel picks the right verb per route', () => { + const copy = { addCredits: 'Add credits', openBilling: 'Open billing' } + expect(billingCtaLabel(makeBlock({ is_nous: true }), copy)).toBe('Open billing') + expect(billingCtaLabel(makeBlock({ is_nous: false }), copy)).toBe('Add credits') +}) diff --git a/apps/desktop/src/store/billing-block.ts b/apps/desktop/src/store/billing-block.ts new file mode 100644 index 000000000000..4d640302b637 --- /dev/null +++ b/apps/desktop/src/store/billing-block.ts @@ -0,0 +1,79 @@ +import type { BillingBlock } from '@hermes/shared' +import { atom } from 'nanostores' + +import { openExternalLink } from '@/lib/external-link' + +/** + * The active inference billing wall, if any. Set from the gateway + * `message.complete` / `error` event when a turn fails with + * `FailoverReason.billing` (see `agent/billing_links.py`). One global slot: a + * credit wall on the active session's provider is the whole app's problem, and + * the newest block wins. Cleared when a new turn starts or the user dismisses. + */ +export interface ActiveBillingBlock { + block: BillingBlock + sessionId: string + at: number +} + +export const $billingBlock = atom(null) + +/** + * Navigation intent counter. A toast fired outside React (or any surface + * without router context) bumps this to ask the shell — which owns + * `useNavigate` — to open Settings → Billing in-app. See `contrib/wiring.tsx`. + */ +export const $billingSettingsRequest = atom(0) + +export function setBillingBlock(sessionId: string, block: BillingBlock): void { + $billingBlock.set({ at: Date.now(), block, sessionId }) +} + +export function clearBillingBlock(sessionId?: string): void { + const current = $billingBlock.get() + + if (!current) { + return + } + + // A scoped clear (new turn on session X) must not wipe a block raised by a + // different session's provider. + if (sessionId && current.sessionId !== sessionId) { + return + } + + $billingBlock.set(null) +} + +export function requestBillingSettings(): void { + $billingSettingsRequest.set($billingSettingsRequest.get() + 1) +} + +/** + * The single recovery action for a billing wall, shared by the toast and the + * in-chat banner so both behave identically: Nous routes to the in-app + * Settings → Billing surface; a third-party provider deep-links to its own + * billing page (falling back to the in-app surface only if we have no URL). + */ +export function runBillingRecovery(block: BillingBlock): void { + if (block.is_nous) { + requestBillingSettings() + + return + } + + if (block.billing_url) { + openExternalLink(block.billing_url) + + return + } + + requestBillingSettings() +} + +export function billingCtaLabel( + block: BillingBlock, + copy: { addCredits: string; openBilling: string } +): string { + return block.is_nous ? copy.openBilling : copy.addCredits +} From 9c274db89ff21a66b524dbce52e496e77147719d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 18:09:08 -0500 Subject: [PATCH 061/238] feat(tui): billing wall opens a confirm dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open the shared ConfirmPrompt (full-width, themed, same lane as approval/clarify) rather than a truncating status-bar notice. One recovery action: Nous → /topup (opens the rich billing overlay), other providers → their billing page, or /model to switch when there's no URL. The transcript keeps the full guidance; the dialog is the concise actionable layer. --- .../createGatewayEventHandler.test.ts | 56 +++++++++++++++++++ ui-tui/src/app/createGatewayEventHandler.ts | 30 ++++++++++ ui-tui/src/gatewayTypes.ts | 13 ++++- ui-tui/src/lib/billingDialog.test.ts | 37 ++++++++++++ ui-tui/src/lib/billingDialog.ts | 36 ++++++++++++ 5 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 ui-tui/src/lib/billingDialog.test.ts create mode 100644 ui-tui/src/lib/billingDialog.ts diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index fdd12d7b1528..42bf75e74d25 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -94,6 +94,62 @@ describe('createGatewayEventHandler', () => { expect(getTurnState().todos).toEqual([]) }) + it('opens a billing confirm dialog routing Nous to /topup', () => { + const appended: Msg[] = [] + const ctx = buildCtx(appended) + const onEvent = createGatewayEventHandler(ctx) + + onEvent({ + payload: { + billing: { + billing_url: null, + is_nous: true, + message: 'out of credits', + model: 'm', + provider: 'nous', + provider_label: 'Nous Portal' + }, + text: 'Billing or credits exhausted: ...' + }, + type: 'message.complete' + } as any) + + const { confirm } = getOverlayState() + expect(confirm?.title).toContain('Nous') + expect(confirm?.confirmLabel).toBe('Top up') + + confirm!.onConfirm() + expect(ctx.submission.submitRef.current).toHaveBeenCalledWith('/topup') + }) + + it('deep-links a third-party provider billing page from the confirm dialog', () => { + const appended: Msg[] = [] + const ctx = buildCtx(appended) + const onEvent = createGatewayEventHandler(ctx) + openExternalUrlMock.mockClear() + + onEvent({ + payload: { + billing: { + billing_url: 'https://openrouter.ai/settings/credits', + is_nous: false, + message: 'out of credits', + model: 'm', + provider: 'openrouter', + provider_label: 'OpenRouter' + }, + text: 'Billing or credits exhausted: ...' + }, + type: 'message.complete' + } as any) + + const { confirm } = getOverlayState() + expect(confirm?.confirmLabel).toBe('Open billing page') + + confirm!.onConfirm() + expect(openExternalUrlMock).toHaveBeenCalledWith('https://openrouter.ai/settings/credits') + }) + it('archives completed todos into transcript flow at end of turn', () => { const appended: Msg[] = [] const todos = [{ content: 'Serve tiny latte', id: 'serve', status: 'completed' }] diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index ec0fb02748ba..912e470140f0 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -13,6 +13,7 @@ import type { GatewaySkin, SessionMostRecentResponse } from '../gatewayTypes.js' +import { billingDialogCopy } from '../lib/billingDialog.js' import { relativeLuminance } from '../lib/color.js' import { isTodoDone } from '../lib/liveProgress.js' import { openExternalUrl } from '../lib/openExternalUrl.js' @@ -1276,6 +1277,35 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: patchUiState(state => ({ ...state, usage: { ...state.usage, ...ev.payload!.usage } })) } + // Billing wall (out of credits / payment required): open a proper + // confirm dialog with the one recovery action, not a truncating status + // notice. The transcript already carries the full provider guidance; + // this is the actionable layer. Set AFTER recordMessageComplete() so the + // turn-idle resetFlowOverlays() (which clears `confirm`) can't wipe it; + // the top-of-loop guard already scopes this to the active session. + if (ev.payload?.billing) { + const block = ev.payload.billing + const copy = billingDialogCopy(block) + + patchOverlayState({ + confirm: { + cancelLabel: copy.cancelLabel, + confirmLabel: copy.confirmLabel, + detail: copy.detail, + onConfirm: () => { + if (block.is_nous) { + submitRef.current('/topup') + } else if (block.billing_url) { + openExternalUrl(block.billing_url) + } else { + submitRef.current('/model') + } + }, + title: copy.title + } + }) + } + return } diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 8ef5ba46fc89..b9cc8f999cb8 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -1,4 +1,4 @@ -import type { UsageModelData } from '@hermes/shared/billing' +import type { BillingBlock, UsageModelData } from '@hermes/shared/billing' import type { HermesSkin } from '@hermes/shared/skin' import type { SessionInfo, SlashCategory, SubagentStatus, Usage } from './types.js' @@ -46,6 +46,7 @@ export interface SlashExecResponse { // Wire shapes now live in @hermes/shared for reuse by TypeScript clients. export type { BillingAutoReload, + BillingBlock, BillingCardInfo, BillingChargeResponse, BillingChargeStatusResponse, @@ -660,7 +661,15 @@ export type GatewayEvent = type: 'message.interim' } | { - payload?: { reasoning?: string; rendered?: string; response_previewed?: boolean; text?: string; usage?: Usage } + payload?: { + billing?: BillingBlock + failure_reason?: string + reasoning?: string + rendered?: string + response_previewed?: boolean + text?: string + usage?: Usage + } session_id?: string type: 'message.complete' } diff --git a/ui-tui/src/lib/billingDialog.test.ts b/ui-tui/src/lib/billingDialog.test.ts new file mode 100644 index 000000000000..ae30b72b4bd7 --- /dev/null +++ b/ui-tui/src/lib/billingDialog.test.ts @@ -0,0 +1,37 @@ +import type { BillingBlock } from '@hermes/shared/billing' +import { describe, expect, it } from 'vitest' + +import { billingDialogCopy } from './billingDialog.js' + +function makeBlock(overrides: Partial = {}): BillingBlock { + return { + billing_url: 'https://openrouter.ai/settings/credits', + is_nous: false, + message: 'out of credits', + model: 'x', + provider: 'openrouter', + provider_label: 'OpenRouter', + ...overrides + } +} + +describe('billingDialogCopy', () => { + it('routes Nous to the /topup flow', () => { + const copy = billingDialogCopy(makeBlock({ is_nous: true, provider: 'nous', provider_label: 'Nous Portal' })) + expect(copy.title).toContain('Nous') + expect(copy.confirmLabel).toBe('Top up') + expect(copy.cancelLabel).toBe('Dismiss') + }) + + it('offers to open a third-party provider billing page', () => { + const copy = billingDialogCopy(makeBlock()) + expect(copy.title).toContain('OpenRouter') + expect(copy.confirmLabel).toBe('Open billing page') + }) + + it('falls back to switching providers when there is no URL', () => { + const copy = billingDialogCopy(makeBlock({ billing_url: null, provider_label: 'DeepSeek' })) + expect(copy.title).toContain('DeepSeek') + expect(copy.confirmLabel).toBe('Switch provider') + }) +}) diff --git a/ui-tui/src/lib/billingDialog.ts b/ui-tui/src/lib/billingDialog.ts new file mode 100644 index 000000000000..9b7e7b8b4013 --- /dev/null +++ b/ui-tui/src/lib/billingDialog.ts @@ -0,0 +1,36 @@ +import type { BillingBlock } from '@hermes/shared/billing' + +export interface BillingDialogCopy { + cancelLabel: string + confirmLabel: string + detail: string + title: string +} + +/** + * Copy for the out-of-credits confirm dialog (the TUI's billing wall). The + * dialog is the actionable layer — the full provider guidance already lands in + * the transcript — so `detail` stays to one concise, non-truncating line and the + * confirm button carries the recovery: Nous → `/topup`, other providers → their + * billing page (or `/model` to switch when we have no URL). Pure + exported so + * the wording is unit-tested without driving the gateway. + */ +export function billingDialogCopy(block: BillingBlock): BillingDialogCopy { + if (block.is_nous) { + return { + cancelLabel: 'Dismiss', + confirmLabel: 'Top up', + detail: 'Your Nous credit balance is exhausted — top up to keep going.', + title: 'Out of Nous credits' + } + } + + const label = block.provider_label || 'your provider' + + return { + cancelLabel: 'Dismiss', + confirmLabel: block.billing_url ? 'Open billing page' : 'Switch provider', + detail: `${label} reports your credits or billing are exhausted.`, + title: `Out of credits · ${label}` + } +} From dd3bd70f35a11773447f94c1dd4659de9aff704a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 18:09:08 -0500 Subject: [PATCH 062/238] feat(cli): out-of-credits panel below the response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin a provider-agnostic "Out of credits" panel after a billing-classified turn so the one recovery action (Nous → /topup, other providers → their billing page) stays visible instead of scrolling away as prose. --- cli.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/cli.py b/cli.py index b9641e53ee20..e0278dee40e8 100644 --- a/cli.py +++ b/cli.py @@ -12607,6 +12607,41 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): width=self._scrollback_box_width(), )) + # Durable, provider-agnostic billing CTA below the response. The + # response panel carries the full guidance; this pins the single + # action to take (Nous → /topup, other providers → their billing + # page) so it stays visible instead of scrolling away as prose. + if result and result.get("failure_reason") == "billing": + _bb = result.get("billing_block") or {} + _prov_label = _bb.get("provider_label") or "your provider" + if _bb.get("is_nous"): + _cta_lines = [ + "Run [bold]/topup[/] to add credits, or " + "[bold]/subscription[/] to change plan.", + ] + else: + _url = _bb.get("billing_url") + _cta_lines = [ + f"Add credits with {_prov_label}" + + (f": [bold]{_url}[/]" if _url else ".") + ] + _cta_lines.append( + "Or switch providers with " + "[bold]/model --provider [/]." + ) + try: + ChatConsole().print(Panel( + "\n".join(_cta_lines), + title="[#CD7F32 bold]⚡ Out of credits[/]", + title_align="left", + border_style="#CD7F32", + box=rich_box.HORIZONTALS, + padding=(1, 4), + width=self._scrollback_box_width(), + )) + except Exception: + pass + # Play terminal bell when agent finishes (if enabled). # Works over SSH — the bell propagates to the user's terminal. From e762ea1741a06c26b2111d3a1021a7b1ba80a207 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 18:11:27 -0500 Subject: [PATCH 063/238] fix(ui-tui): a skin that authors a background owns its polarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The theme engine picked light/dark adaptation from the HOST terminal (detectLightMode) even when the skin authors its own background — which the TUI then paints onto the terminal via OSC-11. On a light-mode Apple Terminal without truecolor, a pure-black skin (e.g. Bloomberg) got its foregrounds ansi256-bucketed *for a light background that no longer exists*: theme.color.text became 'ansi256(214)', which also fails the OSC-10 hex gate, so the terminal default fg stayed the light profile's near-black — markdown body text rendered black-on-black. fromSkin now resolves polarity from the skin's authored background when present (skinIsLight), uses that background as the reference canvas for the derived tone ladder and contrast adaptation, and only falls back to host detection for skins without a background (they render on the terminal's own surface). The paired light_colors/dark_colors pick in themeForSkin follows the same rule. --- ui-tui/src/__tests__/theme.test.ts | 36 +++++++++++++++++++++ ui-tui/src/app/createGatewayEventHandler.ts | 8 +++-- ui-tui/src/theme.ts | 34 ++++++++++++++++--- 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts index ad54c285fa64..fb7f7867025a 100644 --- a/ui-tui/src/__tests__/theme.test.ts +++ b/ui-tui/src/__tests__/theme.test.ts @@ -302,6 +302,42 @@ describe('fromSkin', () => { expect(theme.color.prompt).toBe('ansi256(136)') }) + // ── A skin that authors a background OWNS its polarity ────────────── + // The TUI paints the terminal with the skin's background (OSC-11), so + // every adaptation pass must run against the skin's canvas, not the host + // profile the skin just covered. The real-world failure: a pure-black + // skin on light-mode Apple Terminal got its text ansi256-bucketed for a + // light background that no longer exists — invisible on the painted black. + + it('a dark-background skin on light Apple Terminal keeps its truecolor text (no light-mode bucketing)', async () => { + const { fromSkin } = await importThemeWithEnv({ TERM_PROGRAM: 'Apple_Terminal' }) + + const theme = fromSkin({ background: '#000000', ui_accent: '#ff9e18', ui_text: '#ffa726' }, {}) + + expect(theme.color.text).toBe('#ffa726') + expect(theme.color.prompt).not.toMatch(/^ansi256/) + }) + + it('a skin background outranks the cached host background for adaptation and tone derivation', async () => { + const { fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' }) + + const theme = fromSkin({ background: '#000000', ui_text: '#ffa726' }, {}) + + // Text is not contrast-lifted toward a white host it painted over… + expect(theme.color.text).toBe('#ffa726') + // …and derived fills mix against the skin's black, not the host's white. + expect(luminance(theme.color.completionBg)).toBeLessThanOrEqual(0.35) + expect(luminance(theme.color.statusBg)).toBeLessThanOrEqual(0.35) + }) + + it('skinIsLight: the authored background decides; host detection only when absent', async () => { + const { skinIsLight } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' }) + + expect(skinIsLight({ background: '#000000' })).toBe(false) + expect(skinIsLight({ background: '#f5f5f5' })).toBe(true) + expect(skinIsLight({})).toBe(true) // no canvas of its own → host polarity + }) + it('keeps truecolor light Apple Terminal in truecolor (adapting, not ansi256-bucketing)', async () => { const { contrastRatio, fromSkin } = await importThemeWithEnv({ COLORTERM: 'truecolor', diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index ec0fb02748ba..fccb499b0e4e 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -21,7 +21,7 @@ import { topLevelSubagents } from '../lib/subagentTree.js' import { isPaintableHex, setTerminalBackground, setTerminalForeground } from '../lib/terminalModes.js' import { formatAbandonedClarify, formatToolCall, stripAnsi } from '../lib/text.js' import { bootSeededPin, invalidateBootBackground, writeBootTheme } from '../lib/themeBoot.js' -import { defaultThemeForCurrentBackground, detectLightMode, fromSkin, type Theme } from '../theme.js' +import { defaultThemeForCurrentBackground, fromSkin, skinIsLight, type Theme } from '../theme.js' import type { Msg, SubagentProgress, SubagentStatus } from '../types.js' import { applyDelegationStatus, getDelegationState } from './delegationStore.js' @@ -45,8 +45,10 @@ const themeForSkin = (s: GatewaySkin) => { // can ship a fills-only `light_colors` (flip the dark navy menu/status fills // to light on a light terminal) while its vivid foreground golds keep coming // from `colors` and render raw through fromSkin's shim. A full paired block - // still works — it just overrides every key it lists. - const paired = detectLightMode() ? s.light_colors : s.dark_colors + // still works — it just overrides every key it lists. Polarity follows the + // skin's authored background when it has one (the skin paints the terminal + // with it), else the host's. + const paired = skinIsLight(s.colors ?? {}) ? s.light_colors : s.dark_colors const colors = paired && Object.keys(paired).length ? { ...(s.colors ?? {}), ...paired } : (s.colors ?? {}) diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index bfd777a2f0c3..7ddd75a4e61d 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -788,6 +788,28 @@ export function defaultThemeForCurrentBackground(env: NodeJS.ProcessEnv = proces // ── Skin → Theme ───────────────────────────────────────────────────── +/** The skin's authored canvas as a #-prefixed hex, or null when absent/junk. */ +const authoredBackground = (raw: string | undefined): null | string => { + const v = (raw ?? '').trim() + + return backgroundLuminance(v) === null ? null : v.startsWith('#') ? v : `#${v}` +} + +/** + * A skin that authors a background OWNS its polarity: the TUI paints the + * terminal with it (applySkin → OSC-11), so contrast adaptation, the derived + * tone ladder, and ANSI light-terminal normalization must all run against the + * skin's canvas — not the host profile the skin just covered. (A pure-black + * skin on a light Apple Terminal otherwise gets its text remapped for a light + * background it painted over: invisible.) Host detection still governs skins + * without a background — they render on the terminal's own surface. + */ +export function skinIsLight(colors: SkinColors, env: NodeJS.ProcessEnv = process.env): boolean { + const authored = backgroundLuminance(colors['background'] ?? '') + + return authored === null ? detectLightMode(env) : authored >= LUMA_LIGHT_THRESHOLD +} + export function fromSkin( colors: SkinColors, branding: SkinBranding, @@ -796,11 +818,13 @@ export function fromSkin( toolPrefix = '', helpHeader = '' ): Theme { - // Live detection (not the module-load snapshot): by the time the gateway - // skin arrives, the OSC-11 background probe has usually answered and cached - // itself into HERMES_TUI_BACKGROUND. See #applySkin / syncThemeToTerminalBackground. - const isLight = detectLightMode() - const bg = referenceBackground(isLight) + // Polarity: the skin's own canvas when it authors one (see skinIsLight); + // otherwise live host detection (not the module-load snapshot — by the time + // the gateway skin arrives, the OSC-11 probe has usually answered and cached + // itself into HERMES_TUI_BACKGROUND. See #applySkin / syncThemeToTerminalBackground). + const skinBg = authoredBackground(colors['background']) + const isLight = skinIsLight(colors) + const bg = skinBg ?? referenceBackground(isLight) const base = isLight ? LIGHT_SEEDS : DARK_SEEDS const d = isLight ? LIGHT_THEME : DARK_THEME const c = (k: string) => colors[k] From 55759cb2737cd3870f9de4693f66fa38eaf0dd2b Mon Sep 17 00:00:00 2001 From: ethernet Date: Wed, 22 Jul 2026 19:13:23 -0400 Subject: [PATCH 064/238] fix(desktop): refresh composer branch after worktree creation (#69657) * fix(desktop): refresh composer branch after worktree creation Route new worktree sessions through the shared workspace target handoff so composer git status follows the created worktree instead of remaining on the main checkout. Add an Electron E2E regression test for Ctrl+Shift+B. * fix(desktop): fixme flaky test --- apps/desktop/e2e/warm-resume-jitter.spec.ts | 5 + .../e2e/worktree-branch-status.spec.ts | 94 +++++++++++++++++++ apps/desktop/src/app/contrib/wiring.tsx | 40 +++----- 3 files changed, 110 insertions(+), 29 deletions(-) create mode 100644 apps/desktop/e2e/worktree-branch-status.spec.ts diff --git a/apps/desktop/e2e/warm-resume-jitter.spec.ts b/apps/desktop/e2e/warm-resume-jitter.spec.ts index 04e01e06e8dc..f53241269f39 100644 --- a/apps/desktop/e2e/warm-resume-jitter.spec.ts +++ b/apps/desktop/e2e/warm-resume-jitter.spec.ts @@ -387,6 +387,11 @@ test('warm-route resume paints transcript exactly once (no jitter)', async ({}, }) test('warm-route resume after background inference completes (no jitter)', async ({}, testInfo) => { + test.fixme( + true, + 'Warm resume repaints after inference: expected one additive burst, got two ([18,1]).', + ) + const page = fixture!.page const { mock } = fixture! diff --git a/apps/desktop/e2e/worktree-branch-status.spec.ts b/apps/desktop/e2e/worktree-branch-status.spec.ts new file mode 100644 index 000000000000..8188a074cf01 --- /dev/null +++ b/apps/desktop/e2e/worktree-branch-status.spec.ts @@ -0,0 +1,94 @@ +import { execFileSync } from 'node:child_process' +import * as fs from 'node:fs' +import * as path from 'node:path' + +import { test, expect } from './test' + +import { + buildAppEnv, + createSandbox, + launchDesktop, + writeEnvFile, + writeMockProviderConfig, + type MockBackendFixture, + waitForAppReady, +} from './fixtures' +import { startMockServer } from './mock-server' + +const BRANCH_NAME = 'e2e-composer-branch' + +function createGitRepo(root: string): string { + const repo = path.join(root, 'repo') + + fs.mkdirSync(repo, { recursive: true }) + execFileSync('git', ['init', '--initial-branch=main'], { cwd: repo }) + execFileSync('git', ['config', 'user.email', 'e2e@example.com'], { cwd: repo }) + execFileSync('git', ['config', 'user.name', 'Hermes E2E'], { cwd: repo }) + fs.writeFileSync(path.join(repo, 'README.md'), '# E2E repo\n', 'utf8') + execFileSync('git', ['add', 'README.md'], { cwd: repo }) + execFileSync('git', ['commit', '-m', 'initial'], { cwd: repo }) + + return repo +} + +function configureRepoCwd(hermesHome: string, mockUrl: string, repo: string): void { + writeMockProviderConfig(hermesHome, mockUrl) + fs.appendFileSync(path.join(hermesHome, 'config.yaml'), `\nterminal:\n cwd: ${repo}\n`, 'utf8') + writeEnvFile(hermesHome) +} + +let fixture: MockBackendFixture | null = null + +test.beforeAll(async () => { + const sandbox = createSandbox('worktree-branch-status') + const repo = createGitRepo(sandbox.root) + const mock = await startMockServer() + + configureRepoCwd(sandbox.hermesHome, mock.url, repo) + + const { app, page } = await launchDesktop(buildAppEnv(sandbox)) + fixture = { + app, + page, + mock, + mockUrl: mock.url, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + await mock.close() + sandbox.cleanup() + }, + } + + await waitForAppReady(fixture, 120_000) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test('creating a branch with ctrl-shift-b updates the composer git-status branch', async ({}, testInfo) => { + const page = fixture!.page + const codingRow = page.locator('.coding-status-bar') + const composer = page.locator('[contenteditable="true"]').first() + + await expect(codingRow).toContainText('main') + await composer.click() + await composer.type('create a repo-backed e2e session', { delay: 2 }) + await page.keyboard.press('Enter') + await page.waitForFunction( + prompt => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(prompt), + 'create a repo-backed e2e session', + { timeout: 15_000 }, + ) + await page.keyboard.press('Control+Shift+B') + + const branchInput = page.locator('input[placeholder="e.g. my-feature"]').first() + await expect(branchInput).toBeVisible() + await branchInput.fill(BRANCH_NAME) + await page.getByRole('button', { name: 'New worktree' }).click() + + await expect(codingRow).toContainText(BRANCH_NAME, { timeout: 15_000 }) + await page.screenshot({ path: testInfo.outputPath('composer-branch-after-create.png') }) +}) diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index b2d0c03a9881..dae9c2f0d791 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -31,7 +31,7 @@ import { setCronFocusJobId } from '@/store/cron' import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout' import { $filePreviewTarget, $previewTarget } from '@/store/preview' import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '@/store/profile' -import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '@/store/projects' +import { $startWorkSessionRequest, followActiveSessionCwd } from '@/store/projects' import { $activeSessionId, $connection, @@ -48,8 +48,6 @@ import { sessionPinId, setAwaitingResponse, setBusy, - setCurrentBranch, - setCurrentCwd, setCurrentModel, setCurrentModelSource, setCurrentProvider, @@ -89,6 +87,7 @@ import { useRouteResume } from '../session/hooks/use-route-resume' import { useSessionActions } from '../session/hooks/use-session-actions' import { useSessionListActions } from '../session/hooks/use-session-list-actions' import { useSessionStateCache } from '../session/hooks/use-session-state-cache' +import { startWorkspaceSession } from '../session/workspace-session-target' import { useOverlayRouting } from '../shell/hooks/use-overlay-routing' import { useWindowControlsOverlayWidth } from '../shell/hooks/use-window-controls-overlay-width' import { titlebarControlsPosition } from '../shell/titlebar' @@ -450,33 +449,16 @@ export function ContribWiring({ children }: { children: ReactNode }) { // also drills the sidebar into that project so the new lane is visible. const startSessionInWorkspace = useCallback( (path: null | string) => { - startFreshSessionDraft() - - // A worktree lane carries its own path; the trunk "+" can be path-less - // (the main checkout is implicit), so fall back to the active project's - // root instead of no-op'ing on null. - const target = path?.trim() || resolveNewSessionCwd() - - if (!target) { - return - } - - setCurrentCwd(target) - void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target }) - .then(info => { - const resolved = info.cwd || target - - setCurrentCwd(resolved) - setCurrentBranch(info.branch || '') - - if (path?.trim()) { - restoreWorktree(resolved) - void followActiveSessionCwd(resolved) - } - }) - .catch(() => undefined) + startWorkspaceSession({ + activeSessionIdRef, + followActiveSessionCwd, + onExplicitWorkspace: restoreWorktree, + path, + requestGateway, + startFreshSessionDraft + }) }, - [requestGateway, startFreshSessionDraft] + [activeSessionIdRef, requestGateway, startFreshSessionDraft] ) // Composer "branch off into a new worktree": open a fresh session anchored From c4acc4d2c5eb04492d051657ee6b32d9f407fb0e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 18:17:33 -0500 Subject: [PATCH 065/238] feat(desktop): add prefix/suffix adornments to Input primitive; use $ prefix in billing top-up --- .../src/app/settings/billing/index.test.tsx | 4 +- .../src/app/settings/billing/index.tsx | 123 +++++++++++------- .../src/app/settings/billing/plans-view.tsx | 8 +- .../app/settings/billing/use-billing-state.ts | 12 +- apps/desktop/src/app/settings/primitives.tsx | 28 ++-- apps/desktop/src/components/ui/input.test.tsx | 66 ++++++++++ apps/desktop/src/components/ui/input.tsx | 47 ++++++- .../src/components/ui/segmented-control.tsx | 14 +- apps/desktop/src/styles.css | 1 + 9 files changed, 230 insertions(+), 73 deletions(-) create mode 100644 apps/desktop/src/components/ui/input.test.tsx diff --git a/apps/desktop/src/app/settings/billing/index.test.tsx b/apps/desktop/src/app/settings/billing/index.test.tsx index 65fe9c91cda3..b111d9f1d8e5 100644 --- a/apps/desktop/src/app/settings/billing/index.test.tsx +++ b/apps/desktop/src/app/settings/billing/index.test.tsx @@ -120,7 +120,9 @@ describe('BillingSettings', () => { renderBilling() - expect(await screen.findByText('No card on file')).toBeTruthy() + // No card → the payment row collapses to a single "Add payment method" link. + expect(await screen.findByRole('button', { name: /Add payment method/ })).toBeTruthy() + expect(screen.queryByText('No card on file')).toBeNull() expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(true) expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(true) expect(screen.getByRole('button', { name: '$100' }).hasAttribute('disabled')).toBe(true) diff --git a/apps/desktop/src/app/settings/billing/index.tsx b/apps/desktop/src/app/settings/billing/index.tsx index 2f783827f281..2c2ba0b1ab3a 100644 --- a/apps/desktop/src/app/settings/billing/index.tsx +++ b/apps/desktop/src/app/settings/billing/index.tsx @@ -3,12 +3,13 @@ import { useEffect, useMemo, useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { SegmentedControl } from '@/components/ui/segmented-control' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { BarChart3, CreditCard, ExternalLink, Package, Wrench } from '@/lib/icons' import { cn } from '@/lib/utils' import { useRouteEnumParam } from '../../hooks/use-route-enum-param' -import { ListRow, SectionHeading, SettingsCard, SettingsContent, SettingsSection } from '../primitives' +import { ListRow, SectionHeading, SettingsContent, SettingsSection } from '../primitives' import { RowValue } from './account-row-value' import { BillingApiProvider } from './api' @@ -45,6 +46,16 @@ const BILLING_DEV_FIXTURE_NAMES = import.meta.env.DEV type BillingFixtureSelection = 'live' | BillingDevFixtureName +// DEV-only: a canned ~60%-full "ok" bar so the active progress-bar treatment can be +// eyeballed on any account (a live/empty account only ever shows the hatch track). +const DEV_PREVIEW_USAGE_ROW: BillingUsageRowView = { + bar: { label: 'Example usage (dev preview)', state: 'ok', tone: 'subscription', value: 0.62 }, + caption: 'Preview only — dev build', + id: 'subscription_credits', + title: 'Example bar (dev preview)', + value: '$62 of $100 left' +} + function SummaryCard({ label, value, tone }: { label: string; tone?: 'muted' | 'primary'; value: string }) { return (
@@ -65,16 +76,11 @@ function NoticeCard({ notice }: { notice: BillingNoticeView }) { const warn = notice.tone === 'warn' return ( -
+
{notice.title} @@ -98,6 +104,31 @@ function NoticeCard({ notice }: { notice: BillingNoticeView }) { ) } +// The payment method as it rides in the "Payment & credits" heading: the current +// card (muted) plus a single underline text action (Update / Add payment method). +function PaymentMethodAside({ row }: { row: BillingAccountRowView }) { + return ( +
+ {row.value && ( + + {row.value} + + )} + {row.action && ( + + )} +
+ ) +} + function AccountRow({ billing, row }: { billing?: BillingStateResponse; row: BillingAccountRowView }) { if (row.id === 'buy_credits' && row.action && row.chips && billing?.can_charge && billing.cli_billing_enabled) { return @@ -155,22 +186,15 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B - {presets.map(preset => ( - - ))} + setAmount(value)} + options={presets.map(preset => ({ id: preset.amount, label: preset.label }))} + value={amount} + /> -
@@ -311,8 +336,9 @@ function UsageBar({ bar, fallbackLabel }: { bar?: BillingUsageRowView['bar']; fa resolvedBar.track === 'danger' ? 'dither text-destructive/60 bg-destructive/10' : isEmpty - ? 'dither bg-(--ui-bg-elevated)' - : 'bg-muted shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--ui-stroke-secondary)_50%,transparent)]' + ? // Muted currentColor so the hatch reads as a faint placeholder, not solid ink. + 'dither text-(--ui-text-quaternary) bg-(--ui-bg-elevated)' + : 'bg-(--ui-bg-tertiary) shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--ui-stroke-secondary)_50%,transparent)]' )} role="progressbar" > @@ -381,7 +407,7 @@ function BillingFixtureSelect({ ) + const el = getByRole('textbox') + + expect(el.tagName).toBe('INPUT') + expect(el.className).toContain('desktop-input-chrome') + // No group wrapper — the input is the top-level node. + expect(el.parentElement?.getAttribute('data-slot')).not.toBe('input-group') + }) + + it('wraps the field in a chrome-owning group and renders the prefix when adorned', () => { + const { getByRole, getByText } = render() + const el = getByRole('textbox') + const group = el.closest('[data-slot="input-group"]') + + expect(group).not.toBeNull() + // Chrome moves to the wrapper; the input itself goes transparent/borderless. + expect(group?.className).toContain('desktop-input-chrome') + expect(el.className).not.toContain('desktop-input-chrome') + expect(el.className).toContain('bg-transparent') + + const prefix = getByText('$') + expect(group?.contains(prefix)).toBe(true) + }) + + it('renders a trailing suffix inside the group', () => { + const { getByText } = render() + const suffix = getByText('%') + + expect(suffix.closest('[data-slot="input-group"]')).not.toBeNull() + }) + + it('forwards value/onChange through the adorned field', () => { + const onChange = vi.fn() + const { getByRole } = render() + + fireEvent.change(getByRole('textbox'), { target: { value: '100' } }) + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('dims the group and disables the field when disabled', () => { + const { getByRole } = render() + const el = getByRole('textbox') as HTMLInputElement + const group = el.closest('[data-slot="input-group"]') + + expect(el.disabled).toBe(true) + expect(group?.className).toContain('opacity-50') + }) + + it('lets containerClassName override the wrapper width', () => { + const { getByRole } = render() + const group = getByRole('textbox').closest('[data-slot="input-group"]') + + // twMerge resolves the control's default w-full down to the caller's width. + expect(group?.className).toContain('w-20') + expect(group?.className).not.toContain('w-full') + }) +}) diff --git a/apps/desktop/src/components/ui/input.tsx b/apps/desktop/src/components/ui/input.tsx index a195e9a9ab84..8279cd1d459f 100644 --- a/apps/desktop/src/components/ui/input.tsx +++ b/apps/desktop/src/components/ui/input.tsx @@ -4,8 +4,22 @@ import { cn } from '@/lib/utils' import { type ControlVariantProps, controlVariants } from './control' -function Input({ className, type, size, ...props }: Omit, 'size'> & ControlVariantProps) { - return ( +// `prefix`/`suffix` are DOM string attributes on the native element; we shadow +// them with ReactNode adornments, so they're omitted from the base props. +type InputProps = Omit, 'size' | 'prefix' | 'suffix'> & + ControlVariantProps & { + /** Leading adornment rendered inside the field (e.g. a `$` for money). */ + prefix?: React.ReactNode + /** Trailing adornment rendered inside the field (e.g. a unit label). */ + suffix?: React.ReactNode + /** Applied to the wrapper when an adornment promotes the field to a group. */ + containerClassName?: string + } + +function Input({ className, containerClassName, prefix, suffix, size, type, ...props }: InputProps) { + const grouped = prefix != null || suffix != null + + const field = ( ) + + if (!grouped) { + return field + } + + return ( +
+ {prefix != null && {prefix}} + {field} + {suffix != null && {suffix}} +
+ ) } export { Input } diff --git a/apps/desktop/src/components/ui/segmented-control.tsx b/apps/desktop/src/components/ui/segmented-control.tsx index 994cc17a99bd..45c854ecf2be 100644 --- a/apps/desktop/src/components/ui/segmented-control.tsx +++ b/apps/desktop/src/components/ui/segmented-control.tsx @@ -12,6 +12,8 @@ interface SegmentedControlProps { value: T onChange: (id: T) => void className?: string + /** Dims the whole track and blocks selection (e.g. gated behind a prerequisite). */ + disabled?: boolean } /** @@ -19,11 +21,18 @@ interface SegmentedControlProps { * (color mode, tool-call display, usage period, etc.). Flat by design — * no per-option borders, just a tinted track with a raised active pill. */ -export function SegmentedControl({ options, value, onChange, className }: SegmentedControlProps) { +export function SegmentedControl({ + className, + disabled = false, + onChange, + options, + value +}: SegmentedControlProps) { return (
@@ -34,9 +43,10 @@ export function SegmentedControl({ options, value, onChange, c + + ) : null} {showVoicePrimary ? ( + ))} +

{copy.lateAnswerHint}

+
+ ) : null} ) } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 53802fcd8cdb..42a2c9a1ccf0 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2524,7 +2524,10 @@ export const en: Translations = { placeholder: 'Type your answer…', skip: 'Skip', skipped: 'Skipped', - continueLabel: 'Continue' + continueLabel: 'Continue', + lateAnswer: (question, choice) => `Re: "${question}" — my answer: ${choice}`, + lateAnswerTip: 'Draft this answer as a follow-up message', + lateAnswerHint: 'This question timed out. Picking an option drafts it as a follow-up message.' }, tool: { code: 'Code', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index c712198e6132..673bfa686148 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -2454,7 +2454,10 @@ export const ja = defineLocale({ placeholder: '回答を入力…', skip: 'スキップ', skipped: 'スキップ済み', - continueLabel: '続行' + continueLabel: '続行', + lateAnswer: (question, choice) => `「${question}」について — 私の回答: ${choice}`, + lateAnswerTip: 'この回答をフォローアップメッセージとして下書きします', + lateAnswerHint: 'この質問はタイムアウトしました。選択肢を選ぶとフォローアップメッセージとして下書きされます。' }, tool: { code: 'コード', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 18f96afb90d1..5eff59e68f2d 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -2139,6 +2139,9 @@ export interface Translations { skip: string skipped: string continueLabel: string + lateAnswer: (question: string, choice: string) => string + lateAnswerTip: string + lateAnswerHint: string } tool: { code: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index c19c10bb4769..7a6f77c93614 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -2380,7 +2380,10 @@ export const zhHant = defineLocale({ placeholder: '輸入您的答案…', skip: '略過', skipped: '已略過', - continueLabel: '繼續' + continueLabel: '繼續', + lateAnswer: (question, choice) => `關於「${question}」 — 我的回答: ${choice}`, + lateAnswerTip: '將此回答起草為後續訊息', + lateAnswerHint: '此問題已逾時。選擇一個選項會將其起草為後續訊息。' }, tool: { code: '程式碼', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 8515ec15f429..ba7ed982fe10 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2696,7 +2696,10 @@ export const zh: Translations = { placeholder: '输入你的答案…', skip: '跳过', skipped: '已跳过', - continueLabel: '继续' + continueLabel: '继续', + lateAnswer: (question, choice) => `关于"${question}" — 我的回答: ${choice}`, + lateAnswerTip: '将此回答起草为后续消息', + lateAnswerHint: '此问题已超时。选择一个选项会将其起草为后续消息。' }, tool: { code: '代码', From efa002dd5de43b5a36630b7f0adfce9c74dc0fb7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 21:39:27 -0500 Subject: [PATCH 099/238] refactor(desktop): share the clarify choice row and fix skip copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the skipped-clarify card so it holds up beyond the timeout case: - Extract a shared `ChoiceButton` (letter badge + label + row chrome) used by both the live pending card and the settled skip card. The two blocks had drifted into duplicated markup; now they can't diverge. - Fix the hint copy. An empty `user_response` is emitted for BOTH a server-side timeout AND a manual Skip (tools/clarify_tool.py) — there is no field on the result that tells them apart — so asserting "This question timed out" was wrong half the time. Neutral wording ("This prompt is no longer waiting…") is correct for either, and the recover-your-answer path now also helps someone who mis-clicked Skip. Updated en/ja/zh/zh-hant. No behavior change to the live prompt or the follow-up-message flow. Co-authored-by: SHL0MS --- .../components/assistant-ui/clarify-tool.tsx | 69 ++++++++++++------- apps/desktop/src/i18n/en.ts | 2 +- apps/desktop/src/i18n/ja.ts | 2 +- apps/desktop/src/i18n/zh-hant.ts | 2 +- apps/desktop/src/i18n/zh.ts | 2 +- 5 files changed, 50 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx index d06ed1ef16ea..db33eab308ce 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx @@ -126,6 +126,43 @@ function KeyBadge({ char, preview, selected }: { char: string; preview?: boolean ) } +/** A letter-badged option row. Shared by the live pending card (where a click + * selects an answer) and the settled skip card (where a click drafts a + * follow-up), so both stay visually identical. */ +function ChoiceButton({ + char, + choice, + disabled, + onClick, + selected = false, + title +}: { + char: string + choice: string + disabled?: boolean + onClick: () => void + selected?: boolean + title?: string +}) { + return ( + + ) +} + export const ClarifyTool = (props: ToolCallMessagePartProps) => { // Answered → settled Q&A (ToolFallback collapsed the answer away). if (props.result !== undefined) { @@ -198,20 +235,13 @@ function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) { {skipped && choices.length > 0 ? (
{choices.map((choice, index) => ( - + /> ))}

{copy.lateAnswerHint}

@@ -423,21 +453,14 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { {hasChoices ? (
{choices.map((choice, index) => ( - + selected={selectedChoice === choice} + /> ))}
) From 3dd9d5e6923c8428cb003e481569d2d2d97ea7e5 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Wed, 22 Jul 2026 23:24:42 -0500 Subject: [PATCH 172/238] fix(tui_gateway): tolerate late clarify + terminal.read replies after timeout (#69773) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_block()` bridges four blocking request types — secret, sudo, clarify, terminal.read — with an identical lifecycle: on timeout the tool gives up and returns empty, but a slow renderer (or a WebSocket reconnect that dropped tool.complete) can still answer afterward. Only secret and sudo tolerated that late reply; clarify and terminal.read still hit the generic 4009 "no pending request" error, which clients surface as a raw JSON-RPC string (and at least one desktop fork re-armed the pending request on the error). Bring the two stragglers in line with the pair that already works: - `_block` now emits `{event}.expire` on timeout for all four request types. - `clarify.respond` and `terminal.read.respond` pass `allow_expired=True`, so a late answer resolves to `{"status": "expired"}` instead of erroring. Tests parametrize the timeout-expiry and late-idempotent-response cases over all four bridges; the old test asserting clarify stays a 4009 error is updated to the new graceful contract. Supersedes #56571 (clarify) and #64886 (terminal.read) — same root cause, one fix. Co-authored-by: liuhao1024 Co-authored-by: pierrenode --- tests/tui_gateway/test_protocol.py | 29 ++++++++++++++--------------- tui_gateway/server.py | 25 ++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 4477cd2ca893..de2e6c39b014 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -265,7 +265,10 @@ def test_block_and_respond(capture): assert result[0] == "my_answer" -@pytest.mark.parametrize("event", ["secret.request", "sudo.request"]) +@pytest.mark.parametrize( + "event", + ["secret.request", "sudo.request", "clarify.request", "terminal.read.request"], +) def test_sensitive_prompt_timeout_emits_expiry(capture, event): server, buf = capture @@ -281,9 +284,17 @@ def test_sensitive_prompt_timeout_emits_expiry(capture, event): @pytest.mark.parametrize( ("method", "value_key"), - [("secret.respond", "value"), ("sudo.respond", "password")], + [ + ("secret.respond", "value"), + ("sudo.respond", "password"), + ("clarify.respond", "answer"), + ("terminal.read.respond", "text"), + ], ) -def test_late_sensitive_prompt_response_is_idempotent(server, method, value_key): +def test_late_prompt_response_is_idempotent(server, method, value_key): + """All four blocking bridges tolerate a late reply after their request has + expired — the `*.respond` returns a graceful `{"status": "expired"}` instead + of the raw 4009 protocol error a client would otherwise surface verbatim.""" response = server.handle_request( { "id": "late-response", @@ -295,18 +306,6 @@ def test_late_sensitive_prompt_response_is_idempotent(server, method, value_key) assert response["result"] == {"status": "expired"} -def test_late_clarify_response_remains_protocol_error(server): - response = server.handle_request( - { - "id": "late-clarify", - "method": "clarify.respond", - "params": {"request_id": "expired-request", "answer": ""}, - } - ) - - assert response["error"]["code"] == 4009 - - def test_clear_pending(server): ev = threading.Event() # _pending values are (sid, Event) tuples diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 7adbd8ba8c46..70a47d673e2f 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2431,7 +2431,19 @@ def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str: answer_present = rid in _answers answer = _answers.pop(rid, "") - if not answered and not answer_present and event in {"secret.request", "sudo.request"}: + # Emit an `.expire` notification on timeout for every blocking request type + # whose `*.respond` handler tolerates a late reply (allow_expired=True). + # All four blocking bridges — secret, sudo, clarify, terminal.read — share + # the same lifecycle: the tool gives up on timeout and returns empty, but a + # slow renderer (or a reconnect that dropped tool.complete) can still answer + # afterward. Without this the late `*.respond` would hit the generic 4009 + # "no pending request" error and clients would surface a raw JSON-RPC string. + if not answered and not answer_present and event in { + "secret.request", + "sudo.request", + "clarify.request", + "terminal.read.request", + }: _emit( f"{event.removesuffix('.request')}.expire", sid, @@ -11745,13 +11757,20 @@ def _respond(rid, params, key, *, allow_expired=False): @method("clarify.respond") def _(rid, params: dict) -> dict: - return _respond(rid, params, "answer") + # allow_expired=True: a clarify can time out server-side (its entry is popped + # from _pending) while the card is still visible — common when a WebSocket + # reconnect during the wait drops tool.complete. A late answer must resolve + # gracefully instead of hitting the raw 4009 "no pending answer request". + return _respond(rid, params, "answer", allow_expired=True) @method("terminal.read.respond") def _(rid, params: dict) -> dict: # `text` is a JSON string of the serialized terminal buffer + line metadata. - return _respond(rid, params, "text") + # allow_expired=True: the read_terminal tool's _block() uses a short 30s + # timeout, so a slow renderer losing the race is the common case — a late + # response must not error after the tool already returned empty. + return _respond(rid, params, "text", allow_expired=True) @method("sudo.respond") From 507d479c8c910150d8a929aa32a9e22ee605d0a0 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Wed, 22 Jul 2026 23:25:13 -0500 Subject: [PATCH 173/238] fix(clarify): one canonical timeout across CLI, TUI/desktop, and gateway (#69774) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(clarify-gateway): cover signature, timeout fallback, and notify paths for 100% coverage Fixes #36531 (cherry picked from commit 5265dfe2f5d484c5fb97c71eb311cbb230c0110e) * fix(clarify): one canonical timeout across CLI, TUI/desktop, and gateway The clarify wait timeout was resolved three different (wrong) ways: - CLI (`cli.py`, `hermes_cli/callbacks.py`) read a non-existent top-level `clarify.timeout`, so it always fell through to a hardcoded 120s instead of the canonical `agent.clarify_timeout` (default 3600) the gateway uses (#42969). - The TUI/desktop bridge called `_block("clarify.request", …)` with no timeout, so it used the hardcoded 300s `_block` default and ignored config (#51960). - There was no way to disable the auto-skip: a user who wanted the agent to wait indefinitely while they think couldn't get it. Collapse all of this onto a single resolver: - `tools.clarify_gateway.resolve_clarify_timeout(config)` is the one source of truth. Order: explicit legacy `clarify.timeout` (back-compat) → canonical `agent.clarify_timeout` → 3600. `<= 0` is preserved verbatim as "unlimited". - CLI, callbacks, and the TUI bridge (`_clarify_timeout_seconds`) all route through it, so the three surfaces can't drift. - `<= 0` means unlimited everywhere: `wait_for_response` and `_block` drop the deadline (heartbeat still fires), and the CLI hides its countdown. Tests: resolver order / default / non-numeric / unlimited-sentinel; an unlimited `wait_for_response` blocks until resolved rather than auto-skipping; the TUI clarify bridge passes the configured timeout to `_block`. Supersedes #42974 (CLI key), #51993 (TUI honors config), and #68986 (unlimited wait); folds in #52031 (clarify_gateway coverage). Co-authored-by: liuhao1024 Co-authored-by: lkevincc0 Co-authored-by: theone139344 Co-authored-by: baauzi --------- Co-authored-by: Christopher-Schulze <210261288+Christopher-Schulze@users.noreply.github.com> Co-authored-by: liuhao1024 Co-authored-by: lkevincc0 Co-authored-by: theone139344 Co-authored-by: baauzi --- cli.py | 28 ++++-- hermes_cli/callbacks.py | 19 ++-- tests/test_tui_gateway_server.py | 33 +++++++ tests/tools/test_clarify_gateway.py | 145 ++++++++++++++++++++++++++++ tools/clarify_gateway.py | 52 ++++++++-- tui_gateway/server.py | 25 ++++- 6 files changed, 274 insertions(+), 28 deletions(-) diff --git a/cli.py b/cli.py index 7acf94517f6f..afbdd6cb76ab 100644 --- a/cli.py +++ b/cli.py @@ -11533,7 +11533,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): """ import time as _time - timeout = CLI_CONFIG.get("clarify", {}).get("timeout", 120) + from tools.clarify_gateway import resolve_clarify_timeout + + # Canonical clarify timeout, shared with the gateway/TUI path. `<= 0` + # means unlimited (never auto-skip mid-think) → a null deadline. + timeout = resolve_clarify_timeout(CLI_CONFIG) response_queue = queue.Queue() is_open_ended = not choices @@ -11543,7 +11547,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): "selected": 0, "response_queue": response_queue, } - self._clarify_deadline = _time.monotonic() + timeout + self._clarify_deadline = None if timeout <= 0 else _time.monotonic() + timeout # Open-ended questions skip straight to freetext input self._clarify_freetext = is_open_ended @@ -11560,13 +11564,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): while True: try: result = response_queue.get(timeout=1) - self._clarify_deadline = 0 + self._clarify_deadline = None self._persist_prompt_summary("?", "Clarify", question, str(result)) return result except queue.Empty: - remaining = self._clarify_deadline - _time.monotonic() - if remaining <= 0: - break + # None deadline = unlimited: never auto-skip, just keep polling. + if self._clarify_deadline is not None: + remaining = self._clarify_deadline - _time.monotonic() + if remaining <= 0: + break now = _time.monotonic() if now - _last_countdown_refresh >= 1.0: _last_countdown_refresh = now @@ -11575,7 +11581,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # Timed out — tear down the UI and let the agent decide self._clarify_state = None self._clarify_freetext = False - self._clarify_deadline = 0 + self._clarify_deadline = None self._paint_now() _cprint(f"\n{_DIM}(clarify timed out after {timeout}s — agent will decide){_RST}") return ( @@ -14583,8 +14589,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): ] if cli_ref._clarify_state: - remaining = max(0, int(cli_ref._clarify_deadline - time.monotonic())) - countdown = f' ({remaining}s)' if cli_ref._clarify_deadline else '' + # None deadline = unlimited wait → hide the countdown entirely. + if cli_ref._clarify_deadline is None: + countdown = '' + else: + remaining = max(0, int(cli_ref._clarify_deadline - time.monotonic())) + countdown = f' ({remaining}s)' if cli_ref._clarify_freetext: return [ ('class:hint', ' type your answer and press Enter'), diff --git a/hermes_cli/callbacks.py b/hermes_cli/callbacks.py index b0279ff73deb..f28577cf302b 100644 --- a/hermes_cli/callbacks.py +++ b/hermes_cli/callbacks.py @@ -22,8 +22,11 @@ def clarify_callback(cli, question, choices): responds. Returns the user's choice or a timeout message. """ from cli import CLI_CONFIG + from tools.clarify_gateway import resolve_clarify_timeout - timeout = CLI_CONFIG.get("clarify", {}).get("timeout", 120) + # Canonical clarify timeout, shared with the gateway/TUI path. `<= 0` + # means unlimited (never auto-skip mid-think) → a null deadline. + timeout = resolve_clarify_timeout(CLI_CONFIG) response_queue = queue.Queue() is_open_ended = not choices @@ -33,7 +36,7 @@ def clarify_callback(cli, question, choices): "selected": 0, "response_queue": response_queue, } - cli._clarify_deadline = _time.monotonic() + timeout + cli._clarify_deadline = None if timeout <= 0 else _time.monotonic() + timeout cli._clarify_freetext = is_open_ended if hasattr(cli, "_app") and cli._app: @@ -42,18 +45,20 @@ def clarify_callback(cli, question, choices): while True: try: result = response_queue.get(timeout=1) - cli._clarify_deadline = 0 + cli._clarify_deadline = None return result except queue.Empty: - remaining = cli._clarify_deadline - _time.monotonic() - if remaining <= 0: - break + # None deadline = unlimited: never auto-skip, just keep polling. + if cli._clarify_deadline is not None: + remaining = cli._clarify_deadline - _time.monotonic() + if remaining <= 0: + break if hasattr(cli, "_app") and cli._app: cli._app.invalidate() cli._clarify_state = None cli._clarify_freetext = False - cli._clarify_deadline = 0 + cli._clarify_deadline = None if hasattr(cli, "_app") and cli._app: cli._app.invalidate() cprint(f"\n{_DIM}(clarify timed out after {timeout}s — agent will decide){_RST}") diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 4387eab7d5cf..eb295799270d 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -12110,3 +12110,36 @@ def test_tts_stream_vad_barge_in_cuts_pipeline_and_submits_capture(monkeypatch, assert not wav.exists() # capture temp file cleaned up assert ts.take_speech_interrupted() is True # VAD cut latches the model note server._tts_stream_stop() + + +def test_clarify_callback_uses_configured_timeout(monkeypatch): + """The TUI/desktop clarify bridge honors the canonical clarify timeout + (via _clarify_timeout_seconds) instead of the hardcoded _block default.""" + captured = {} + + monkeypatch.setattr(server, "_clarify_timeout_seconds", lambda: 42) + + def fake_block(event, sid, payload, timeout=300): + captured.update(event=event, sid=sid, payload=payload, timeout=timeout) + return "answer" + + monkeypatch.setattr(server, "_block", fake_block) + + result = server._agent_cbs("sid-1")["clarify_callback"]("Pick one", ["a", "b"]) + + assert result == "answer" + assert captured["event"] == "clarify.request" + assert captured["timeout"] == 42 + assert captured["payload"] == {"question": "Pick one", "choices": ["a", "b"]} + + +@pytest.mark.parametrize( + ("configured", "expected"), + [(0, None), (-1, None), (42, 42)], +) +def test_clarify_timeout_seconds_maps_non_positive_to_unlimited(monkeypatch, configured, expected): + """A ``<= 0`` clarify timeout means unlimited and reaches _block as None + (ev.wait(None) waits forever) rather than an immediate ev.wait(0) skip.""" + monkeypatch.setattr("tools.clarify_gateway.get_clarify_timeout", lambda: configured) + + assert server._clarify_timeout_seconds() == expected diff --git a/tests/tools/test_clarify_gateway.py b/tests/tools/test_clarify_gateway.py index a7082708a6e5..fab1ebedcc96 100644 --- a/tests/tools/test_clarify_gateway.py +++ b/tests/tools/test_clarify_gateway.py @@ -294,3 +294,148 @@ class TestGatewayTextIntercept: # Clean up cm.clear_session("sk-tf") + + +class TestCoverageGaps: + """Cover remaining branches: signature(), get_entry miss, find_awaiting + with deleted entry, cancel with None entry, timeout exception, get_notify.""" + + def setup_method(self): + _clear_clarify_state() + + def test_entry_signature(self): + """_ClarifyEntry.signature() returns the expected dict.""" + from tools import clarify_gateway as cm + + entry = cm.register("sig1", "sk", "Q?", ["A", "B"]) + sig = entry.signature() + assert sig["clarify_id"] == "sig1" + assert sig["session_key"] == "sk" + assert sig["question"] == "Q?" + assert sig["choices"] == ["A", "B"] + + def test_entry_signature_no_choices(self): + """signature() returns None for choices when open-ended.""" + from tools import clarify_gateway as cm + + entry = cm.register("sig2", "sk", "Q?", None) + sig = entry.signature() + assert sig["choices"] is None + + def test_wait_for_response_unknown_id_returns_none(self): + """wait_for_response on a non-existent id returns None immediately.""" + from tools import clarify_gateway as cm + + assert cm.wait_for_response("nonexistent-id", timeout=0.1) is None + + def test_find_awaiting_skips_deleted_entry(self): + """get_pending_for_session skips entries that were removed from _entries + but still listed in _session_index.""" + from tools import clarify_gateway as cm + + cm.register("a1", "sk", "Q?", None) + # Manually remove from _entries but leave in _session_index + with cm._lock: + cm._entries.pop("a1", None) + # No entry to find → returns None + assert cm.get_pending_for_session("sk") is None + + def test_clear_session_skips_deleted_entry(self): + """clear_session skips entries that are None (already removed).""" + from tools import clarify_gateway as cm + + cm.register("c1", "sk", "Q?", ["A"]) + # Manually remove from _entries but leave in _session_index + with cm._lock: + cm._entries.pop("c1", None) + # Should return 0 cancelled (entry was already gone) + cancelled = cm.clear_session("sk") + assert cancelled == 0 + + def test_get_clarify_timeout_exception_returns_default(self, monkeypatch): + """get_clarify_timeout returns 3600 when load_config raises.""" + from tools import clarify_gateway as cm + + monkeypatch.setattr("hermes_cli.config.load_config", + lambda: (_ for _ in ()).throw(RuntimeError("boom"))) + assert cm.get_clarify_timeout() == 3600 + + def test_get_notify_returns_callback(self): + """get_notify returns the registered callback.""" + from tools import clarify_gateway as cm + + cb = lambda entry: None + cm.register_notify("sk-notify", cb) + assert cm.get_notify("sk-notify") is cb + + def test_get_notify_returns_none_when_not_registered(self): + """get_notify returns None for an unregistered session.""" + from tools import clarify_gateway as cm + + assert cm.get_notify("unregistered") is None + + +class TestClarifyTimeoutResolution: + """resolve_clarify_timeout is the single source of truth for the clarify + timeout, shared by the CLI, TUI/desktop, and messaging-gateway paths.""" + + def test_canonical_agent_key(self): + from tools import clarify_gateway as cm + + assert cm.resolve_clarify_timeout({"agent": {"clarify_timeout": 900}}) == 900 + + def test_legacy_clarify_key_overrides(self): + """An explicitly-set legacy top-level clarify.timeout wins, for + back-compat with users who set it before agent.clarify_timeout existed.""" + from tools import clarify_gateway as cm + + cfg = {"clarify": {"timeout": 42}, "agent": {"clarify_timeout": 900}} + assert cm.resolve_clarify_timeout(cfg) == 42 + + def test_default_when_unset(self): + from tools import clarify_gateway as cm + + assert cm.resolve_clarify_timeout({}) == 3600 + + def test_non_numeric_falls_back_to_default(self): + from tools import clarify_gateway as cm + + assert cm.resolve_clarify_timeout({"agent": {"clarify_timeout": "nope"}}) == 3600 + + def test_non_positive_preserved_as_unlimited_sentinel(self): + """<= 0 is passed through verbatim — the waiting loops read it as + 'unlimited', so the resolver must not clamp it to a positive default.""" + from tools import clarify_gateway as cm + + assert cm.resolve_clarify_timeout({"agent": {"clarify_timeout": 0}}) == 0 + assert cm.resolve_clarify_timeout({"clarify": {"timeout": -1}}) == -1 + + +class TestUnlimitedWait: + """timeout <= 0 makes wait_for_response block until the answer arrives + instead of auto-skipping.""" + + def setup_method(self): + _clear_clarify_state() + + def test_zero_timeout_waits_until_resolved(self): + from tools import clarify_gateway as cm + + cm.register("u1", "sk", "Q?", ["A", "B"]) + result_box = {} + + def waiter(): + result_box["r"] = cm.wait_for_response("u1", timeout=0) + + t = threading.Thread(target=waiter) + t.start() + # An unlimited wait cannot finish while nothing resolves it: still + # running after a comfortable margin (old code auto-skipped at once). + t.join(timeout=1.5) + assert t.is_alive() + + # Once resolved, the unlimited wait returns the real answer. + cm.resolve_gateway_clarify("u1", "B") + t.join(timeout=5.0) + assert not t.is_alive() + assert result_box["r"] == "B" diff --git a/tools/clarify_gateway.py b/tools/clarify_gateway.py index be527008a22d..bb7ed4a97070 100644 --- a/tools/clarify_gateway.py +++ b/tools/clarify_gateway.py @@ -108,6 +108,10 @@ def wait_for_response(clarify_id: str, timeout: float) -> Optional[str]: for 10 minutes with zero activity touches and the gateway's inactivity watchdog kills the agent while the user is still typing. + ``timeout <= 0`` means an unlimited wait (never auto-skip mid-think); the + heartbeat still fires each slice so inactivity watchdogs don't kill a live + prompt. + Returns the resolved response string, or ``None`` on timeout. """ with _lock: @@ -120,13 +124,19 @@ def wait_for_response(clarify_id: str, timeout: float) -> Optional[str]: except Exception: # pragma: no cover - optional touch_activity_if_due = None - deadline = time.monotonic() + max(timeout, 0.0) + # 0 / negative → unlimited: no deadline, poll forever in 1s slices. + unlimited = timeout is None or float(timeout) <= 0.0 + deadline = None if unlimited else time.monotonic() + float(timeout) activity_state = {"last_touch": time.monotonic(), "start": time.monotonic()} while True: - remaining = deadline - time.monotonic() - if remaining <= 0: - break - if entry.event.wait(timeout=min(1.0, remaining)): + if deadline is None: + slice_s = 1.0 + else: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + slice_s = min(1.0, remaining) + if entry.event.wait(timeout=slice_s): break if touch_activity_if_due is not None: touch_activity_if_due(activity_state, "waiting for user clarify response") @@ -300,6 +310,29 @@ def clear_session(session_key: str) -> int: # Config # ========================================================================= +def resolve_clarify_timeout(config: dict) -> int: + """Resolve the clarify timeout (seconds) from an already-loaded config dict. + + Single source of truth shared by every surface (messaging gateway, CLI, + TUI/desktop) so the timeout can't drift between them. Resolution order: + + 1. legacy top-level ``clarify.timeout`` if a user explicitly set it, + 2. else the canonical ``agent.clarify_timeout``, + 3. else 3600 (1 hour). + + ``<= 0`` is preserved verbatim and means *unlimited* to callers (never + auto-skip while the user is still deciding); the waiting loops translate + that into a null deadline. A non-numeric value falls back to 3600. + """ + raw = (config.get("clarify") or {}).get("timeout") + if raw is None: + raw = (config.get("agent") or {}).get("clarify_timeout", 3600) + try: + return int(raw) + except (TypeError, ValueError): + return 3600 + + def get_clarify_timeout() -> int: """Read the clarify response timeout (seconds) from config. @@ -311,13 +344,14 @@ def get_clarify_timeout() -> int: tap landed on a dead entry and the agent hung on ``running: clarify`` (#32762). - Reads ``agent.clarify_timeout`` from config.yaml. + Reads ``agent.clarify_timeout`` from config.yaml (see + :func:`resolve_clarify_timeout` for the full resolution order). Set to + ``0`` (or negative) for an unlimited wait — never auto-skip while the user + is still deciding. """ try: from hermes_cli.config import load_config - cfg = load_config() or {} - agent_cfg = cfg.get("agent", {}) or {} - return int(agent_cfg.get("clarify_timeout", 3600)) + return resolve_clarify_timeout(load_config() or {}) except Exception: return 3600 diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 70a47d673e2f..e1a415708022 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2411,7 +2411,7 @@ def _enable_gateway_prompts() -> None: # ── Blocking prompt factory ────────────────────────────────────────── -def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str: +def _block(event: str, sid: str, payload: dict, timeout: float | None = 300) -> str: rid = uuid.uuid4().hex[:8] ev = threading.Event() with _prompt_lock: @@ -2423,7 +2423,10 @@ def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str: answer_present = False try: _emit(event, sid, payload) - answered = ev.wait(timeout=timeout) + # Natural Event semantics: None → wait forever (clarify configured with + # clarify_timeout <= 0, released only by a real answer or + # session.interrupt), 0 → return immediately, > 0 → bounded wait. + answered = ev.wait(timeout) finally: with _prompt_lock: _pending.pop(rid, None) @@ -2452,6 +2455,19 @@ def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str: return answer +def _clarify_timeout_seconds() -> float | None: + """Clarify wait (seconds) for the TUI/desktop bridge, from the same + canonical config the messaging gateway and CLI use. Falls back to the + historical 300s _block default if config can't be read. ``<= 0`` in config + means unlimited and is returned as ``None`` (never auto-skip).""" + try: + from tools.clarify_gateway import get_clarify_timeout + timeout = get_clarify_timeout() + return timeout if timeout > 0 else None + except Exception: + return 300 + + def _clear_pending(sid: str | None = None) -> None: """Release pending prompts with an empty answer. @@ -4531,7 +4547,10 @@ def _agent_cbs(sid: str) -> dict: "notification.clear", sid, {"key": key} ), "clarify_callback": lambda q, c: _block( - "clarify.request", sid, {"question": q, "choices": c} + "clarify.request", + sid, + {"question": q, "choices": c}, + timeout=_clarify_timeout_seconds(), ), # read_terminal tool (desktop GUI): same blocking bridge as clarify — the # renderer answers terminal.read.respond with the serialized buffer. From 7d28e84e72ec6f66cae12139b983c782d19ede66 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Wed, 22 Jul 2026 23:25:35 -0500 Subject: [PATCH 174/238] fix(gateway): deliver assistant prose before the clarify poll (#69775) * fix(gateway): deliver assistant prose before clarify poll The clarify poll is sent on a separate, agent-thread-blocking path while buffered assistant prose (interim commentary / streamed deltas) sits in the GatewayStreamConsumer queue, drained asynchronously. The poll won the race, so the question rendered ABOVE its own explanation, and a redundant 'clarify: ...' tool-progress bubble wedged between them. - Add GatewayStreamConsumer.flush_pending_sync(): a synchronous flush barrier (_FLUSH sentinel + threading.Event) that blocks the agent thread until everything queued before it is finalized and delivered. - Call it in the gateway clarify callback before send_clarify, so prose always lands before the poll. Best-effort with a 3s timeout. - Suppress the redundant clarify tool-progress bubble (the poll already shows the question + options). Tests: 3 new ordering/timeout cases in test_stream_consumer.py. (cherry picked from commit 9a6e27badb7646c839902db1d2f4a8affcf38b1e) * chore(contributors): map matvey.sakhnenko03@icloud.com -> sakhnenkoff Attribution mapping for the salvaged #54328 commit (Cluster C). --------- Co-authored-by: Matvii Sakhnenko --- .../emails/matvey.sakhnenko03@icloud.com | 2 + gateway/run.py | 19 ++ gateway/stream_consumer.py | 96 +++++++++- tests/gateway/test_stream_consumer.py | 170 ++++++++++++++++++ 4 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 contributors/emails/matvey.sakhnenko03@icloud.com diff --git a/contributors/emails/matvey.sakhnenko03@icloud.com b/contributors/emails/matvey.sakhnenko03@icloud.com new file mode 100644 index 000000000000..5cbfa28b81e1 --- /dev/null +++ b/contributors/emails/matvey.sakhnenko03@icloud.com @@ -0,0 +1,2 @@ +sakhnenkoff +# PR #69775 salvage of #54328 diff --git a/gateway/run.py b/gateway/run.py index 3b20e4d74d6a..bbba54ad70ba 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -21016,6 +21016,25 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew except Exception: pass + # Ordering barrier (#clarify-ordering): flush any buffered + # assistant prose (interim commentary / streamed deltas) to the + # platform BEFORE sending the poll. The poll is delivered on a + # separate, agent-thread-blocking path; without this barrier it + # races ahead of prose still sitting in the stream consumer's + # queue, so the question renders ABOVE its own explanation. + # Best-effort + short timeout: never hang the agent thread if + # the consumer task isn't running. + try: + _sc = stream_consumer_holder[0] if stream_consumer_holder else None + _flush = getattr(_sc, "flush_pending_sync", None) + if callable(_flush): + _flush(timeout=3.0) + except Exception: + logger.debug( + "Stream-consumer flush before clarify prompt failed", + exc_info=True, + ) + send_ok = False fut = safe_schedule_threadsafe( _status_adapter.send_clarify( diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index 0e9a623a7174..a269aa8198d3 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -20,6 +20,7 @@ import inspect import logging import queue import re +import threading import time from dataclasses import dataclass from typing import Any, Callable, Optional @@ -50,6 +51,15 @@ _NEW_SEGMENT = object() # API/tool iterations (for example: "I'll inspect the repo first."). _COMMENTARY = object() +# Queue marker for a synchronous flush barrier. Enqueued as +# ``(_FLUSH, threading.Event)``; the drain loop finalizes and delivers any +# buffered segment, then sets the event. A caller on the agent worker thread +# uses this (via ``flush_pending_sync``) to block until everything queued +# BEFORE the marker has actually landed on the platform — needed before +# sending a blocking interactive prompt (clarify poll) so the prompt is the +# last thing on screen, not racing ahead of buffered prose. +_FLUSH = object() + @dataclass class StreamConsumerConfig: @@ -341,6 +351,29 @@ class GatewayStreamConsumer: if text: self._queue.put((_COMMENTARY, text)) + def flush_pending_sync(self, timeout: float = 5.0) -> bool: + """Block the calling (agent worker) thread until everything queued + before this point has been finalized and delivered to the platform. + + Enqueues a ``(_FLUSH, Event)`` barrier behind any pending deltas / + commentary / segment breaks. The async ``run()`` task processes those + first (FIFO), then handles the barrier — finalizing the current segment + and setting the event. Returns True if the flush completed within + ``timeout``, False on timeout (so the caller continues rather than + hanging if the consumer task is not running / already finished). + + This is the ordering barrier used before sending a blocking interactive + prompt (clarify poll): without it, the poll — sent on a separate, + agent-thread-blocking path — races ahead of buffered prose that is still + sitting in this queue, so the question lands ABOVE its own explanation. + """ + evt = threading.Event() + try: + self._queue.put((_FLUSH, evt)) + except Exception: + return False + return evt.wait(timeout=max(0.0, float(timeout))) + def _notify_new_message(self) -> None: """Fire the on_new_message callback, swallowing any errors.""" cb = self._on_new_message @@ -351,6 +384,23 @@ class GatewayStreamConsumer: except Exception: logger.debug("on_new_message callback error", exc_info=True) + @staticmethod + def _signal_flush(flush_event) -> None: + """Wake a thread blocked in flush_pending_sync(), swallowing errors. + + Centralised so every loop exit path that consumed a ``_FLUSH`` barrier + (the normal bottom-of-iteration path AND early ``continue`` paths such + as the oversized-prose overflow split) reliably sets the event. Missing + a set is not a deadlock — the caller uses a bounded timeout — but it + would make the caller stall the full timeout before its blocking send. + """ + if flush_event is None: + return + try: + flush_event.set() + except Exception: + pass + def _reset_segment_state(self, *, preserve_no_edit: bool = False) -> None: if preserve_no_edit and self._message_id == "__no_edit__": return @@ -599,6 +649,8 @@ class GatewayStreamConsumer: # Drain all available items from the queue got_done = False got_segment_break = False + got_flush = False + flush_event = None commentary_text = None while True: try: @@ -612,6 +664,14 @@ class GatewayStreamConsumer: if isinstance(item, tuple) and len(item) == 2 and item[0] is _COMMENTARY: commentary_text = item[1] break + if isinstance(item, tuple) and len(item) == 2 and item[0] is _FLUSH: + # Flush barrier: finalize the current segment like a + # tool boundary, then signal the waiting thread once + # delivery for this iteration has completed (below). + got_flush = True + got_segment_break = True + flush_event = item[1] + break self._filter_and_accumulate(item) except queue.Empty: break @@ -717,8 +777,15 @@ class GatewayStreamConsumer: self._message_id = None self._fallback_final_send = False self._fallback_prefix = "" - continue + # This iteration consumed a _FLUSH barrier and delivered + # the buffered prose via the chunk loop above, then takes + # an early `continue` that skips the bottom-of-loop set. + # Signal here so flush_pending_sync() doesn't stall the + # full timeout waiting on already-delivered content. + if got_flush: + self._signal_flush(flush_event) + continue # Existing message: edit it with the first chunk, then # start a new message for the overflow remainder. while ( @@ -875,6 +942,13 @@ class GatewayStreamConsumer: await self._flush_segment_tail_on_edit_failure() self._reset_segment_state(preserve_no_edit=True) + # Flush barrier satisfied: the buffered segment (if any) has now + # been finalized and delivered above, so wake the thread blocked + # in flush_pending_sync(). Done last so the waiter only unblocks + # once everything queued before the barrier is on screen. + if got_flush: + self._signal_flush(flush_event) + await asyncio.sleep(0.05) # Small yield to not busy-loop except asyncio.CancelledError: @@ -907,6 +981,26 @@ class GatewayStreamConsumer: self._final_content_delivered = True except Exception as e: logger.error("Stream consumer error: %s", e) + finally: + # Safety net: if run() exits (normal return, cancellation, or + # exception) while a _FLUSH barrier is still queued or was consumed + # but not yet signaled, wake any waiters now. Without this a caller + # blocked in flush_pending_sync() would stall the full timeout when + # the consumer dies mid-flush. Bounded either way, but this makes + # the common case instant instead of timeout-delayed. + try: + while True: + item = self._queue.get_nowait() + if ( + isinstance(item, tuple) + and len(item) == 2 + and item[0] is _FLUSH + ): + self._signal_flush(item[1]) + except queue.Empty: + pass + except Exception: + pass # Strip MEDIA: tags before display. Uses the shared anchored # MEDIA_TAG_CLEANUP_RE from gateway/platforms/base.py — only tags whose diff --git a/tests/gateway/test_stream_consumer.py b/tests/gateway/test_stream_consumer.py index 430f44da02cb..f1b8ffa399cd 100644 --- a/tests/gateway/test_stream_consumer.py +++ b/tests/gateway/test_stream_consumer.py @@ -2416,3 +2416,173 @@ class TestHasDeliveredTextAfterSegmentBreak: c._reset_segment_state() assert c.has_delivered_text("") is False assert c.has_delivered_text(" ") is False + + +# ── Flush barrier (clarify-ordering) tests ─────────────────────────────── + + +class TestFlushPendingSync: + """flush_pending_sync() is the ordering barrier that guarantees buffered + prose lands on the platform BEFORE a blocking interactive prompt (clarify + poll) is sent. Regression coverage for the bug where the poll raced ahead + of its own explanation, rendering the question above the prose. + """ + + @pytest.mark.asyncio + async def test_flush_delivers_buffered_commentary_before_returning(self): + """A commentary message queued before the flush barrier must be sent + to the adapter before flush_pending_sync() returns True.""" + adapter = MagicMock() + sent_order = [] + + async def _send(*args, **kwargs): + sent_order.append(("send", kwargs.get("content", ""))) + return SimpleNamespace(success=True, message_id="msg_1") + + async def _edit(*args, **kwargs): + sent_order.append(("edit", kwargs.get("content", ""))) + return SimpleNamespace(success=True) + + adapter.send = AsyncMock(side_effect=_send) + adapter.edit_message = AsyncMock(side_effect=_edit) + adapter.MAX_MESSAGE_LENGTH = 4096 + + config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5) + consumer = GatewayStreamConsumer(adapter, "chat_123", config) + + # Mirror the live config that exhibits the bug: streaming off but + # interim assistant messages on, so prose arrives as commentary. + consumer.on_commentary("Now I get the full picture. Here's the situation.") + + # Run the drain loop concurrently while we block on the flush barrier + # from a worker thread (mirrors the agent thread calling the clarify + # callback while the consumer task drains on the event loop). + task = asyncio.create_task(consumer.run()) + flushed = await asyncio.to_thread(consumer.flush_pending_sync, 3.0) + + assert flushed is True, "flush_pending_sync should complete within timeout" + # The commentary must already be on screen by the time flush returns. + assert any( + "full picture" in content for _kind, content in sent_order + ), f"Commentary not delivered before flush returned: {sent_order!r}" + + consumer.finish() + await task + + @pytest.mark.asyncio + async def test_flush_times_out_when_consumer_not_running(self): + """If the consumer task is not running, flush_pending_sync returns + False (rather than hanging the caller forever).""" + adapter = MagicMock() + adapter.send = AsyncMock(return_value=SimpleNamespace(success=True, message_id="m1")) + adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=True)) + adapter.MAX_MESSAGE_LENGTH = 4096 + + config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5) + consumer = GatewayStreamConsumer(adapter, "chat_123", config) + + # run() is never started — the barrier is never drained. + flushed = await asyncio.to_thread(consumer.flush_pending_sync, 0.1) + assert flushed is False + + @pytest.mark.asyncio + async def test_flush_with_empty_buffer_still_completes(self): + """A flush with nothing buffered still completes (no-op barrier).""" + adapter = MagicMock() + adapter.send = AsyncMock(return_value=SimpleNamespace(success=True, message_id="m1")) + adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=True)) + adapter.MAX_MESSAGE_LENGTH = 4096 + + config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5) + consumer = GatewayStreamConsumer(adapter, "chat_123", config) + + task = asyncio.create_task(consumer.run()) + flushed = await asyncio.to_thread(consumer.flush_pending_sync, 3.0) + assert flushed is True + + consumer.finish() + await task + + @pytest.mark.asyncio + async def test_flush_completes_on_oversized_buffered_prose(self): + """Regression: oversized prose takes the overflow-split `continue` + path in run(), which previously skipped the flush-event set, stalling + the caller for the full timeout. The flush must still complete promptly + and the prose must be delivered before it returns.""" + adapter = MagicMock() + sent = [] + + async def _send(*args, **kwargs): + sent.append(kwargs.get("content", "")) + return SimpleNamespace(success=True, message_id=f"m{len(sent)}") + + adapter.send = AsyncMock(side_effect=_send) + adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=True)) + adapter.MAX_MESSAGE_LENGTH = 4096 + # Real splitter so the overflow branch (message_id is None + + # len > safe_limit) is actually taken. + adapter.truncate_message = ( + lambda text, limit, len_fn=len: [ + text[i:i + limit] for i in range(0, len(text), limit) + ] + ) + + config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5) + consumer = GatewayStreamConsumer(adapter, "chat_123", config) + + # Commentary far larger than the platform limit → overflow split path. + big = "X" * 9000 + consumer.on_commentary(big) + + task = asyncio.create_task(consumer.run()) + # Tight timeout: if the continue-path skips the set, this returns False. + flushed = await asyncio.to_thread(consumer.flush_pending_sync, 2.0) + + assert flushed is True, ( + "flush stalled — overflow `continue` path did not signal the barrier" + ) + assert sent, "oversized prose was not delivered before flush returned" + assert sum(len(c) for c in sent) >= 9000 + + consumer.finish() + await task + + @pytest.mark.asyncio + async def test_flush_signaled_when_consumer_cancelled(self): + """If run() is cancelled while a flush barrier is queued, the finally + safety-net wakes the waiter rather than letting it hit the full + timeout.""" + adapter = MagicMock() + # Make the first send hang so the consumer is mid-iteration when we + # cancel it, with the flush barrier still in the queue behind it. + started = asyncio.Event() + + async def _slow_send(*args, **kwargs): + started.set() + await asyncio.sleep(60) + return SimpleNamespace(success=True, message_id="m1") + + adapter.send = AsyncMock(side_effect=_slow_send) + adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=True)) + adapter.MAX_MESSAGE_LENGTH = 4096 + + config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5) + consumer = GatewayStreamConsumer(adapter, "chat_123", config) + consumer.on_commentary("first") + + task = asyncio.create_task(consumer.run()) + await started.wait() # consumer is now blocked inside the slow send + # Queue the flush barrier behind the hung send. + flush_done = asyncio.get_event_loop().run_in_executor( + None, consumer.flush_pending_sync, 5.0 + ) + await asyncio.sleep(0.05) + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + # The finally net should have drained + signaled the queued barrier. + flushed = await flush_done + assert flushed is True From d21165c2f08dedd36b968bdd609085e52c89ec81 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Wed, 22 Jul 2026 23:25:48 -0500 Subject: [PATCH 175/238] fix(desktop): keep clarify answerable across reconnect/hydration + tool-progress off (#69795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(desktop): keep clarify lifecycle when tool progress is off * fix(desktop): render clarify prompt from the request event Re-authored onto the current use-message-stream/gateway-event.ts (the original patched the pre-split use-message-stream.ts). When the tool.start row that normally mounts the inline clarify UI is missed (stream reconnect / hydration race), upsert a stable pending clarify tool row from clarify.request itself so the prompt stays answerable; a real tool.start/complete with the same request id merges rather than duplicates. Co-authored-by: 정수환 * chore(contributors): map centerid@naver.com -> lidises Attribution mapping for the salvaged #47544 commit. * fix(desktop): correlate clarify rows by question so hydration can't duplicate The hydrated row (from clarify.request's request_id) and the real tool.start row (the model's tool_call_id) have different ids, so id-only matching appended a second clarify card in the normal path (caught by the BLOCKING_CLARIFY e2e: 'question' resolved to 2 elements). Add 'question' to the tool match-value keys so a clarify upsert merges into the existing pending clarify row regardless of id (same request<->args correlation ClarifyToolPending already uses); when no row exists yet (reconnect/hydration) it still creates one. --------- Co-authored-by: 정수환 --- .../clarify-hydration.test.tsx | 116 ++++++++++++++++++ .../hooks/use-message-stream/gateway-event.ts | 23 +++- apps/desktop/src/lib/chat-messages.ts | 8 +- contributors/emails/centerid@naver.com | 2 + tests/test_tui_gateway_server.py | 40 ++++++ tui_gateway/server.py | 12 +- 6 files changed, 191 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/app/session/hooks/use-message-stream/clarify-hydration.test.tsx create mode 100644 contributors/emails/centerid@naver.com diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/clarify-hydration.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/clarify-hydration.test.tsx new file mode 100644 index 000000000000..6af488231fe9 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/clarify-hydration.test.tsx @@ -0,0 +1,116 @@ +import { QueryClient } from '@tanstack/react-query' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { type MutableRefObject, useEffect, useRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ClientSessionState } from '@/app/types' +import { createClientSessionState } from '@/lib/chat-runtime' +import { clearClarifyRequest } from '@/store/clarify' +import type { RpcEvent } from '@/types/hermes' + +import { useMessageStream } from './index' + +// A `clarify.request` must leave an answerable inline row even when the +// `tool.start` that normally mounts it was missed (stream reconnect / +// hydration race). Without it the sidebar says "needs input" but the +// transcript has nowhere to render the choices, so the agent blocks forever. + +const SID = 'session-1' + +let handleEvent: ((event: RpcEvent) => void) | null = null +let stateRef: MutableRefObject> | null = null + +function Harness() { + const activeSessionIdRef = useRef(SID) + const sessionStateByRuntimeIdRef = useRef(new Map()) + const queryClientRef = useRef(new QueryClient()) + + const stream = useMessageStream({ + activeSessionIdRef, + hydrateFromStoredSession: vi.fn(async () => undefined), + queryClient: queryClientRef.current, + refreshHermesConfig: vi.fn(async () => undefined), + refreshSessions: vi.fn(async () => undefined), + sessionStateByRuntimeIdRef, + updateSessionState: (sessionId, updater) => { + const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState() + const next = updater(current) + sessionStateByRuntimeIdRef.current.set(sessionId, next) + + return next + } + }) + + useEffect(() => { + handleEvent = stream.handleGatewayEvent + stateRef = sessionStateByRuntimeIdRef + }, [stream.handleGatewayEvent]) + + return null +} + +async function mountStream() { + render() + await waitFor(() => expect(handleEvent).not.toBeNull()) +} + +const clarifyRequest = (payload: Record) => + act(() => handleEvent!({ payload, session_id: SID, type: 'clarify.request' })) + +const toolStart = (payload: Record) => + act(() => handleEvent!({ payload, session_id: SID, type: 'tool.start' })) + +function clarifyParts() { + const messages = stateRef?.current.get(SID)?.messages ?? [] + + return messages.flatMap(m => m.parts).filter(p => p.type === 'tool-call' && p.toolName === 'clarify') +} + +describe('clarify.request stream hydration', () => { + beforeEach(() => { + handleEvent = null + stateRef = null + clearClarifyRequest() + }) + + afterEach(() => { + cleanup() + clearClarifyRequest() + vi.restoreAllMocks() + }) + + it('mounts an answerable clarify row when the tool.start row was missed', async () => { + await mountStream() + + clarifyRequest({ choices: ['yes', 'no'], question: 'Ship it?', request_id: 'req-1' }) + + const parts = clarifyParts() + expect(parts).toHaveLength(1) + expect(parts[0].type === 'tool-call' && parts[0].toolCallId).toBe('req-1') + expect(parts[0].type === 'tool-call' && parts[0].args).toMatchObject({ + choices: ['yes', 'no'], + question: 'Ship it?' + }) + }) + + it('merges with the real tool.start row even though its id differs from the request id', async () => { + await mountStream() + + // Reality: tool.start carries the model's tool_call_id, clarify.request a + // separately-generated request_id. They must still collapse to ONE card + // (correlated by question), not two. + toolStart({ args: { choices: ['a'], question: 'Pick' }, name: 'clarify', tool_id: 'call-abc' }) + clarifyRequest({ choices: ['a'], question: 'Pick', request_id: 'req-2' }) + + expect(clarifyParts()).toHaveLength(1) + }) + + it('does not duplicate when clarify.request arrives before the tool.start row', async () => { + await mountStream() + + clarifyRequest({ choices: ['a'], question: 'Pick', request_id: 'req-3' }) + toolStart({ args: { choices: ['a'], question: 'Pick' }, name: 'clarify', tool_id: 'call-xyz' }) + + expect(clarifyParts()).toHaveLength(1) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 950cccf60a7b..801678c26644 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -679,19 +679,30 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { const question = typeof payload?.question === 'string' ? payload.question : '' if (requestId && question) { + const choices = Array.isArray(payload?.choices) ? payload!.choices!.filter(c => typeof c === 'string') : null + setClarifyRequest({ requestId, question, - choices: Array.isArray(payload?.choices) ? payload!.choices!.filter(c => typeof c === 'string') : null, + choices, sessionId: sessionId ?? null }) - // The transcript only renders the active session, so a background - // clarify is otherwise invisible (the row just keeps spinning like - // it's working). Flag the session so the sidebar shows a persistent - // "needs input" indicator on its row — works for the active session - // too, and survives alt-tab / window blur (unlike a toast). if (sessionId) { + // `clarify.request` is the blocking event the Python side waits on, + // while the inline UI normally mounts from the earlier `tool.start` + // row. If that row was missed (stream reconnect / hydration race) the + // sidebar still says "needs input" but there is nowhere to render the + // choices. Upsert a stable pending clarify tool row from the request + // itself so the prompt stays answerable; a real tool.start/complete + // with the same request id merges rather than duplicates. + upsertToolCall(sessionId, { args: { choices, question }, name: 'clarify', tool_id: requestId }, 'running') + + // The transcript only renders the active session, so a background + // clarify is otherwise invisible (the row just keeps spinning like + // it's working). Flag the session so the sidebar shows a persistent + // "needs input" indicator on its row — works for the active session + // too, and survives alt-tab / window blur (unlike a toast). updateSessionState(sessionId, state => ({ ...state, needsInput: true })) } diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index 828021b21eaa..06ef904f2dda 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -356,7 +356,11 @@ function collectToolMatchValues(query: string, context: string, preview: string) function toolPayloadMatchValues(payload: GatewayEventPayload | undefined): string[] { const payloadArgs = liveToolArgs(payload) - const query = firstStringField(payloadArgs, ['search_term', 'query']) + // `question` is clarify's identifying arg: a synthetic row hydrated from + // `clarify.request` (a fresh request id) must correlate with the `tool.start` + // row (the model's tool_call_id) so the two ids don't produce a duplicate + // clarify card — same correlation ClarifyToolPending uses for request↔args. + const query = firstStringField(payloadArgs, ['search_term', 'query', 'question']) const context = typeof payload?.context === 'string' ? payload.context.trim() : '' const preview = typeof payload?.preview === 'string' ? payload.preview.trim() : '' @@ -369,7 +373,7 @@ function toolPartMatchValues(part: ChatMessagePart): string[] { } const args = part.args as Record - const query = firstStringField(args, ['search_term', 'query']) + const query = firstStringField(args, ['search_term', 'query', 'question']) const context = typeof args.context === 'string' ? args.context.trim() : '' const preview = typeof args.preview === 'string' ? args.preview.trim() : '' diff --git a/contributors/emails/centerid@naver.com b/contributors/emails/centerid@naver.com new file mode 100644 index 000000000000..37ddc7854012 --- /dev/null +++ b/contributors/emails/centerid@naver.com @@ -0,0 +1,2 @@ +lidises +# PR salvage of #47544 diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index eb295799270d..161bdd671652 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -865,6 +865,46 @@ def test_tui_tool_output_risk_event_exposes_metadata_without_raw_output(monkeypa assert "result" not in events[0][2] +def test_tui_clarify_lifecycle_events_emit_when_tool_progress_off(monkeypatch): + events: list[tuple[str, str, dict]] = [] + monkeypatch.setattr( + server, "_emit", lambda event_type, sid, payload: events.append((event_type, sid, payload)) + ) + monkeypatch.setitem( + server._sessions, + "clarify-off-test", + {"tool_progress_mode": "off", "tool_started_at": {}}, + ) + + args = {"question": "Pick one", "choices": ["A", "B"]} + result = '{"question":"Pick one","choices_offered":["A","B"],"user_response":"A"}' + + server._on_tool_start("clarify-off-test", "tool-clarify", "clarify", args) + server._on_tool_complete("clarify-off-test", "tool-clarify", "clarify", args, result) + + assert [event[0] for event in events] == ["tool.start", "tool.complete"] + assert events[0][2]["name"] == "clarify" + assert events[0][2]["tool_id"] == "tool-clarify" + assert events[1][2]["result"]["user_response"] == "A" + + +def test_tui_non_interactive_tool_lifecycle_stays_hidden_when_tool_progress_off(monkeypatch): + events: list[tuple[str, str, dict]] = [] + monkeypatch.setattr( + server, "_emit", lambda event_type, sid, payload: events.append((event_type, sid, payload)) + ) + monkeypatch.setitem( + server._sessions, + "terminal-off-test", + {"tool_progress_mode": "off", "tool_started_at": {}}, + ) + + server._on_tool_start("terminal-off-test", "tool-1", "terminal", {"command": "pwd"}) + server._on_tool_complete("terminal-off-test", "tool-1", "terminal", {"command": "pwd"}, "done") + + assert events == [] + + def test_dispatch_rejects_non_object_request(): resp = server.dispatch([]) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e1a415708022..0488a5753b2d 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3299,6 +3299,14 @@ def _tool_progress_enabled(sid: str) -> bool: return _session_tool_progress_mode(sid) != "off" +def _tool_lifecycle_required_for_ui(name: str) -> bool: + """Return True for tool events that are interactive UI, not optional chrome.""" + # Desktop renders the clarify choices/question from the tool-call part, then + # wires request_id from clarify.request. If tool progress is off, suppressing + # clarify's lifecycle events leaves only the sidebar attention dot visible. + return name == "clarify" + + def _restart_slash_worker(sid: str, session: dict): worker = session.get("slash_worker") if worker: @@ -4236,7 +4244,7 @@ def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict): except Exception: pass session.setdefault("tool_started_at", {})[tool_call_id] = time.time() - if _tool_progress_enabled(sid): + if _tool_progress_enabled(sid) or _tool_lifecycle_required_for_ui(name): payload = { "tool_id": tool_call_id, "name": name, @@ -4294,7 +4302,7 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result payload["inline_diff"] = "\n".join(rendered) except Exception: pass - if _tool_progress_enabled(sid) or payload.get("inline_diff"): + if _tool_progress_enabled(sid) or payload.get("inline_diff") or _tool_lifecycle_required_for_ui(name): _emit("tool.complete", sid, payload) From dc861964fc6d7eb05d3757752e0c80d6626ed474 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Wed, 22 Jul 2026 23:32:33 -0500 Subject: [PATCH 176/238] fix(desktop): clarify options defensive rendering and layout fix (#69796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace undefined CSS class with Tailwind v4's so long choice text wraps properly instead of overflowing the button container. - Add validation that strips non-string items, blanks, newlines, and text >200 chars — preventing garbage/JSON arrays from rendering as raw values. - Add diagnostic logging at both the gateway event handler and the tool-args parser when choices are dropped, so malformed payloads are no longer silent. - Apply in both the gateway event handler () and the inline tool-args parser () for consistent defense in depth. - Add unit tests for covering null, non-array, mixed-type, blank, multiline, and overlong inputs. Closes #69122 Co-authored-by: webtecnica --- .../hooks/use-message-stream/gateway-event.ts | 10 +++-- .../components/assistant-ui/clarify-tool.tsx | 15 +++++-- apps/desktop/src/store/clarify.test.ts | 41 +++++++++++++++++++ apps/desktop/src/store/clarify.ts | 30 ++++++++++++++ 4 files changed, 89 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 801678c26644..432258d16ae4 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -17,7 +17,7 @@ import { modelOptionsQueryKey } from '@/lib/model-options' import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors' import { reconcileApprovalModeForProfile } from '@/store/approval-mode' import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block' -import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify' +import { clearClarifyRequest, normalizeChoices, setClarifyRequest, warnDroppedChoices } from '@/store/clarify' import { setSessionCompacting } from '@/store/compaction' import { refreshBackgroundProcesses } from '@/store/composer-status' import { $gateway } from '@/store/gateway' @@ -677,14 +677,18 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { // over; the inline ClarifyTool reads the active session's entry. const requestId = typeof payload?.request_id === 'string' ? payload.request_id : '' const question = typeof payload?.question === 'string' ? payload.question : '' + const rawChoices = payload?.choices + const choices = normalizeChoices(rawChoices) if (requestId && question) { - const choices = Array.isArray(payload?.choices) ? payload!.choices!.filter(c => typeof c === 'string') : null + if (rawChoices != null && choices.length === 0) { + warnDroppedChoices('gateway', question, rawChoices) + } setClarifyRequest({ requestId, question, - choices, + choices: choices.length > 0 ? choices : null, sessionId: sessionId ?? null }) diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx index d5c04d7acdc4..23cfe9ad52e1 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx @@ -24,7 +24,7 @@ import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { CircleLetterA, Loader2, MessageQuestion } from '@/lib/icons' import { cn } from '@/lib/utils' -import { clearClarifyRequest, sessionClarifyRequest } from '@/store/clarify' +import { clearClarifyRequest, normalizeChoices, sessionClarifyRequest, warnDroppedChoices } from '@/store/clarify' import { $gateway } from '@/store/gateway' import { notifyError } from '@/store/notifications' @@ -54,11 +54,18 @@ function stringField(row: Record, ...keys: string[]): string | function readClarifyArgs(args: unknown): ClarifyArgs { const row = parseMaybeObject(args) - const choices = Array.isArray(row.choices) ? row.choices.filter((c): c is string => typeof c === 'string') : null + const rawChoices = row.choices + const choices = normalizeChoices(rawChoices) + + const question = stringField(row, 'question') + + if (rawChoices != null && choices.length === 0 && question) { + warnDroppedChoices('tool_args', question, rawChoices) + } return { - question: stringField(row, 'question'), - choices: choices && choices.length > 0 ? choices : null + question, + choices: choices.length > 0 ? choices : null } } diff --git a/apps/desktop/src/store/clarify.test.ts b/apps/desktop/src/store/clarify.test.ts index 269004b49f1a..ed995a7a52a6 100644 --- a/apps/desktop/src/store/clarify.test.ts +++ b/apps/desktop/src/store/clarify.test.ts @@ -5,6 +5,7 @@ import { $clarifyRequests, type ClarifyRequest, clearClarifyRequest, + normalizeChoices, setClarifyRequest } from './clarify' import { $activeSessionId } from './session' @@ -79,3 +80,43 @@ describe('clarify store', () => { expect($clarifyRequests.get()['session-b']?.requestId).toBe('other') }) }) + +describe('normalizeChoices', () => { + it('returns empty array for null/undefined', () => { + expect(normalizeChoices(null)).toEqual([]) + expect(normalizeChoices(undefined)).toEqual([]) + }) + + it('returns empty array for non-array input', () => { + expect(normalizeChoices('hello')).toEqual([]) + expect(normalizeChoices(42)).toEqual([]) + expect(normalizeChoices({})).toEqual([]) + }) + + it('filters out non-string items', () => { + expect(normalizeChoices(['a', 42, 'b', null, 'c'])).toEqual(['a', 'b', 'c']) + }) + + it('drops blank and whitespace-only strings', () => { + expect(normalizeChoices(['a', '', 'b', ' ', 'c'])).toEqual(['a', 'b', 'c']) + }) + + it('drops strings with newlines', () => { + expect(normalizeChoices(['a', 'b\nc', 'd'])).toEqual(['a', 'd']) + }) + + it('drops strings over 200 chars', () => { + const long = 'x'.repeat(201) + const ok = 'y'.repeat(200) + expect(normalizeChoices(['a', long, ok])).toEqual(['a', ok]) + }) + + it('drops empty items and keeps valid ones', () => { + expect(normalizeChoices(['valid', ' ', '', 'also valid'])).toEqual(['valid', 'also valid']) + }) + + it('returns empty array when nothing survives', () => { + expect(normalizeChoices(['', ' ', null, undefined])).toEqual([]) + expect(normalizeChoices([])).toEqual([]) + }) +}) diff --git a/apps/desktop/src/store/clarify.ts b/apps/desktop/src/store/clarify.ts index 59449a946887..f90dcd13174b 100644 --- a/apps/desktop/src/store/clarify.ts +++ b/apps/desktop/src/store/clarify.ts @@ -9,6 +9,36 @@ export interface ClarifyRequest { sessionId: string | null } +/** + * Validate and normalize a choices array. + * + * Keeps non-blank, newline-free strings of length ≤ 200; drops everything else + * and returns an empty array when nothing usable survives — the caller then + * falls back to a free-text answer instead of dead buttons. + */ +export function normalizeChoices(choices: unknown): string[] { + if (!Array.isArray(choices)) { + return [] + } + + return choices.filter( + (c): c is string => typeof c === 'string' && c.trim().length > 0 && c.length <= 200 && !c.includes('\n') + ) +} + +/** + * Structured warning for a clarify payload that arrived with choices but had + * them all normalized away — keeps the remaining #69122 "no selectable choices" + * triggers diagnosable in the field without dead constant fields. + */ +export function warnDroppedChoices(source: 'gateway' | 'tool_args', question: string, rawChoices: unknown): void { + console.warn('[clarify] choices dropped after normalization', { + choices_count: Array.isArray(rawChoices) ? rawChoices.length : 0, + question_length: question.length, + source + }) +} + // Pending clarify requests keyed by the runtime session id that raised them. // Storing per-session (instead of one shared slot) lets a *background* session // park its clarify request while the user is looking at a different chat, then From 328e4f5a1e0a2d4980a65a4b940805619815406a Mon Sep 17 00:00:00 2001 From: "hermes-seaeye[bot]" <307254004+hermes-seaeye[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 04:40:24 +0000 Subject: [PATCH 177/238] fmt(js): `npm run fix` on merge (#69852) Co-authored-by: github-actions[bot] --- apps/desktop/src/app/chat/composer/controls.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index e56525476a47..996b962f5448 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -116,7 +116,13 @@ export function ComposerControls({ label={ busy ? ( ) : ( From 5fccc9aae9c3a698efb2bf37b8b9607c75eaac73 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 23:42:43 -0500 Subject: [PATCH 178/238] fix(desktop): stop model-switch dup + route recovery resumes to the owning profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes two Desktop session-reconciliation symptoms from #67603. Symptom 1 — duplicated user bubble after a model switch. The gateway persists model-switch / personality notices as role=user `[System: …]` rows (tui_gateway/server.py) so strict OpenAI-compatible providers don't reject a non-leading system message (#48338). `preserveLocalPendingTurnMessages` paired local optimistic rows with the stored transcript by user-role ordinal, so a marker between two real user turns shifted every later ordinal and the optimistic row was re-appended at the bottom. The single trailing-marker case is already covered by the compression-era `latestAuthoritativeUser` guard, but two switches around one turn (marker before AND after the committed prompt) still duplicated it. Exclude `[System:` bookkeeping markers from ordinal pairing on both sides. Symptom 2 — a session appearing under two profiles. The main resume path already resolves a session's owning profile via `resolveStoredSession` (cache → active backend → cross-profile probe), but the recovery `session.resume` calls (stale runtime id, session-not-found, wedged loop, redirect) omitted `profile`, so the gateway fell back to the launch-profile DB and forked the conversation into the wrong profile. Route every recovery resume — and an uncached right-click branch — through the same resolver so the profile is carried even for sessions outside the paginated sidebar window (the cache-miss gap). Tests: discriminating two-switch marker test (fails before, passes after); cache-hit + cross-profile cache-miss coverage for the recovery resume and for branching an uncached session. Supersedes #68665 and #63590. Closes #67603. Co-authored-by: Dolverin <5910064+Dolverin@users.noreply.github.com> Co-authored-by: oliviaaaa7788 <274182427+oliviaaaa7788@users.noreply.github.com> --- .../hooks/use-prompt-actions/index.test.tsx | 95 +++++++++++++++++++ .../session/hooks/use-prompt-actions/index.ts | 11 ++- .../hooks/use-prompt-actions/submit.ts | 13 ++- .../hooks/use-session-actions.test.tsx | 36 ++++++- .../hooks/use-session-actions/index.ts | 8 +- .../hooks/use-session-actions/utils.test.ts | 52 ++++++++++ .../hooks/use-session-actions/utils.ts | 40 ++++++++ 7 files changed, 249 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index f27fc5b750a5..a7de88bf4de3 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -3,6 +3,7 @@ import type { MutableRefObject } from 'react' import { useEffect, useRef } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getSession } from '@/hermes' import { textPart } from '@/lib/chat-messages' import { $composerAttachments, $composerDraft, type ComposerAttachment, setComposerDraft } from '@/store/composer' import { $notifications, clearNotifications } from '@/store/notifications' @@ -25,6 +26,7 @@ import { uploadComposerAttachment, usePromptActions } from '.' vi.mock('@/hermes', () => ({ getProfiles: vi.fn(async () => ({ profiles: [] })), + getSession: vi.fn(), PROMPT_SUBMIT_REQUEST_TIMEOUT_MS: 1_800_000, setApiRequestProfile: vi.fn(), transcribeAudio: vi.fn() @@ -1834,6 +1836,99 @@ describe('usePromptActions sleep/wake session recovery', () => { expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' }) }) + // #67603 (second symptom): a recovery resume must re-register on the session's + // OWNING profile. Resuming on whichever profile is live forks the conversation + // into the wrong profile's DB — the session then appears under both profiles. + it('carries the owning profile from the cache into the recovery resume', async () => { + setSessions(() => [sessionInfo({ id: STORED_SESSION_ID, profile: 'work' })]) + + const calls: { method: string; params?: Record }[] = [] + let submitAttempts = 0 + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + calls.push({ method, params }) + + if (method === 'prompt.submit') { + submitAttempts += 1 + + if (submitAttempts === 1) { + throw new Error('session not found') + } + + return {} as never + } + + if (method === 'session.resume') { + return { session_id: RECOVERED_SESSION_ID } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + storedSessionId={STORED_SESSION_ID} + /> + ) + + expect(await handle!.submitText('message after wake')).toBe(true) + expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', profile: 'work' }) + + setSessions(() => []) + }) + + // The session lives on another profile and is outside the paginated sidebar + // cache: resolve it by id across profiles rather than resuming profile-blind. + it('resolves the owning profile across profiles when the session is not cached', async () => { + // module-factory vi.fn is not reset by restoreAllMocks — reset explicitly in + // the finally below so this resolved value never leaks into sibling tests. + setSessions(() => []) + vi.mocked(getSession).mockResolvedValue(sessionInfo({ id: STORED_SESSION_ID, profile: 'work' })) + + const calls: { method: string; params?: Record }[] = [] + let submitAttempts = 0 + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + calls.push({ method, params }) + + if (method === 'prompt.submit') { + submitAttempts += 1 + + if (submitAttempts === 1) { + throw new Error('session not found') + } + + return {} as never + } + + if (method === 'session.resume') { + return { session_id: RECOVERED_SESSION_ID } as never + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + await actRender( + (handle = h)} + refreshSessions={async () => undefined} + requestGateway={requestGateway} + storedSessionId={STORED_SESSION_ID} + /> + ) + + expect(await handle!.submitText('message after wake')).toBe(true) + expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', profile: 'work' }) + + vi.mocked(getSession).mockReset() + setSessions(() => []) + }) + it('background queue resume uses the queued stored id and leaves foreground runtime selected', async () => { const calls: { method: string; params?: Record }[] = [] let submitAttempts = 0 diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index f7edf24f14c1..1e314fe714a8 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -43,6 +43,7 @@ import type { ImageAttachResponse, SessionRedirectResponse } from '../../../types' +import { resolveSessionProfile } from '../use-session-actions/utils' import { applyBranchVisibility, @@ -601,9 +602,12 @@ export function usePromptActions({ if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) { try { + const resumeProfile = await resolveSessionProfile(selectedStoredSessionIdRef.current) + const resumed = await requestGateway<{ session_id: string }>('session.resume', { session_id: selectedStoredSessionIdRef.current, - source: 'desktop' + source: 'desktop', + ...(resumeProfile ? { profile: resumeProfile } : {}) }) const recoveredId = resumed?.session_id @@ -700,9 +704,12 @@ export function usePromptActions({ // correction right after a reconnect isn't lost to the race. if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) { try { + const resumeProfile = await resolveSessionProfile(selectedStoredSessionIdRef.current) + const resumed = await requestGateway<{ session_id: string }>('session.resume', { session_id: selectedStoredSessionIdRef.current, - source: 'desktop' + source: 'desktop', + ...(resumeProfile ? { profile: resumeProfile } : {}) }) const recoveredId = resumed?.session_id diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index deee5b0683ba..398a72f3be4a 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -24,6 +24,7 @@ import { setAwaitingResponse, setBusy, setMessages } from '@/store/session' import type { ClientSessionState } from '../../../types' import { sessionContextDrift } from '../session-context-drift' +import { resolveSessionProfile } from '../use-session-actions/utils' import { _submitInFlight, @@ -384,9 +385,14 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // background queue drain only has the durable id). Continue that target // conversation; only a genuine new-chat draft may create a new session. try { + // Re-register on the session's OWNING profile — resuming on whichever + // profile is live would fork the conversation into the wrong DB (#67603). + const resumeProfile = await resolveSessionProfile(targetStoredSessionId) + const resumed = await requestGateway<{ session_id: string }>('session.resume', { session_id: targetStoredSessionId, - source: 'desktop' + source: 'desktop', + ...(resumeProfile ? { profile: resumeProfile } : {}) }) const resumeDrift = sessionDriftReason() @@ -528,9 +534,12 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // backend loop (#55578 symptom d) rejects the submit even though // the stored session is fine — resume + retry instead of erroring // out and losing the session binding. + const resumeProfile = await resolveSessionProfile(recoverStoredSessionId) + const resumed = await requestGateway<{ session_id: string }>('session.resume', { session_id: recoverStoredSessionId, - source: 'desktop' + source: 'desktop', + ...(resumeProfile ? { profile: resumeProfile } : {}) }) const resumeRetryDrift = sessionDriftReason() diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 5188a11f8d26..f0a4139f7990 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -3,7 +3,7 @@ import type { MutableRefObject } from 'react' import { useEffect } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { getSessionMessages, type SessionInfo } from '@/hermes' +import { getSession, getSessionMessages, type SessionInfo } from '@/hermes' import { createClientSessionState } from '@/lib/chat-runtime' import { clearSessionDraft, stashSessionDraft, takeSessionDraft } from '@/store/composer' import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile } from '@/store/profile' @@ -43,6 +43,7 @@ import { useSessionActions } from './use-session-actions' vi.mock('@/hermes', async importOriginal => ({ ...(await importOriginal>()), deleteSession: vi.fn(), + getSession: vi.fn(), getSessionMessages: vi.fn(), listAllProfileSessions: vi.fn(), setApiRequestProfile: vi.fn(), @@ -1088,6 +1089,39 @@ describe('branchStoredSession desktop source tagging', () => { source: 'desktop' }) }) + + // #67603: right-clicking a session outside the paginated sidebar window is a + // cache miss. Resolve its owning profile (cache → active → cross-profile) and + // swap to it before reading the transcript / creating the branch, so the fork + // is not created on whichever profile happens to be live. + it('resolves and swaps to the parent profile when the branched session is not cached', async () => { + setSessions([]) + vi.mocked(getSession).mockResolvedValue(storedSession({ id: 'stored-parent', message_count: 1, profile: 'work' })) + vi.mocked(getSessionMessages).mockResolvedValue({ + messages: [{ content: 'branch me', role: 'user', timestamp: 1 }], + session_id: 'stored-parent' + } as never) + + const requestGateway = vi.fn(async (method: string) => { + if (method === 'session.create') { + return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never + } + + return {} as never + }) + + let branchStoredSession: ((storedSessionId: string, sessionProfile?: string | null) => Promise) | null = + null + render( (branchStoredSession = branch)} requestGateway={requestGateway} />) + await waitFor(() => expect(branchStoredSession).not.toBeNull()) + + await expect(branchStoredSession!('stored-parent')).resolves.toBe(true) + + expect(ensureGatewayProfile).toHaveBeenCalledWith('work') + expect(getSessionMessages).toHaveBeenCalledWith('stored-parent', 'work') + + vi.mocked(getSession).mockReset() + }) }) // ── Warm-cache mapping integrity (the "open chat A, chat B loads" bug) ───────── diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 75aaf8cd6cab..a4ac8ec4ad72 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -1177,7 +1177,13 @@ export function useSessionActions({ async (storedSessionId: string, sessionProfile?: string | null): Promise => { clearNotifications() - const stored = $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) + // Right-clicking a session outside the paginated sidebar window is a cache + // miss: resolve it (cache → active backend → cross-profile) so the branch + // is created on the parent's OWNING profile, not whichever is live (#67603). + const stored = + $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ?? + (sessionProfile ? undefined : await resolveStoredSession(storedSessionId)) + const profile = sessionProfile ?? stored?.profile try { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts index d2fab21de6ca..2f0d7c05f5f9 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts @@ -361,6 +361,58 @@ describe('preserveLocalPendingTurnMessages', () => { expect(preserveLocalPendingTurnMessages(compressedAuthority, pollutedWarmCache)).toBe(compressedAuthority) }) + + // #67603: the gateway persists model-switch / personality notices as role=user + // ([System: …], tui_gateway/server.py). A single trailing marker is already + // handled by the latestAuthoritativeUser guard above, but TWO switches around + // one turn put a marker BEFORE the committed prompt (shifting its ordinal) and + // another AFTER it (so the prompt is no longer the last user row, so the text + // guard can't rescue it). Naive ordinal pairing then pairs the optimistic row + // against a marker, treats it as uncommitted, and re-appends it — the + // duplicated user bubble stacked at the bottom of the chat. + it('does not duplicate the optimistic prompt when markers bracket it (two model switches)', () => { + const marker = (name: string) => `[System: The active model for this chat has changed to ${name}.]` + + const previous = [ + msg('1-user', 'user', 'first'), + msg('2-assistant', 'assistant', 'first answer'), + msg('user-optimistic', 'user', 'second question') + ] + + const next = [ + msg('s1-user', 'user', 'first'), + msg('s2-assistant', 'assistant', 'first answer'), + msg('s3-marker', 'user', marker('k2')), + msg('s4-user', 'user', 'second question'), + msg('s5-assistant', 'assistant', 'second answer'), + msg('s6-marker', 'user', marker('k3')) + ] + + expect(preserveLocalPendingTurnMessages(next, previous)).toBe(next) + }) + + it('still keeps a genuinely uncommitted optimistic turn when a marker is present', () => { + const previous = [ + msg('1-user', 'user', 'first'), + msg('2-assistant', 'assistant', 'first answer'), + msg('user-optimistic', 'user', 'new question') + ] + + // The marker is persisted but the new prompt has not committed yet — the + // optimistic row must survive (marker exclusion must not over-correct). + const next = [ + msg('1-user-stored', 'user', 'first'), + msg('2-assistant-stored', 'assistant', 'first answer'), + msg('3-marker-stored', 'user', '[System: The active model for this chat has changed to k3.]') + ] + + expect(preserveLocalPendingTurnMessages(next, previous).map(message => message.id)).toEqual([ + '1-user-stored', + '2-assistant-stored', + '3-marker-stored', + 'user-optimistic' + ]) + }) }) describe('appendLiveSessionProjection', () => { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index f724a8f3e2cf..55781c3bcb7a 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -240,7 +240,17 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes * history, not in-flight work. The latest authoritative user confirms whether * that tail has persisted; any authoritative assistant at the same ordinal * supersedes the local stream. + * + * Gateway bookkeeping markers (the model-switch / personality notices written + * by tui_gateway/server.py) are persisted as role=user but are not user turns. + * They must not take part in ordinal pairing on either side: a stored marker + * between two real user turns shifts every later user ordinal, so the optimistic + * row misses its committed copy and is appended a second time at the end of the + * transcript — the duplicated user bubble of #67603. */ +const isGatewaySystemMarker = (message: ChatMessage): boolean => + message.role === 'user' && chatMessageText(message).trimStart().startsWith('[System:') + export function preserveLocalPendingTurnMessages( nextMessages: ChatMessage[], previousMessages: ChatMessage[] @@ -253,6 +263,10 @@ export function preserveLocalPendingTurnMessages( const nextRoleCounts = new Map() for (const message of nextMessages) { + if (isGatewaySystemMarker(message)) { + continue + } + const ordinal = nextRoleCounts.get(message.role) ?? 0 nextRoleCounts.set(message.role, ordinal + 1) nextByRoleOrdinal.set(`${message.role}:${ordinal}`, message) @@ -269,6 +283,10 @@ export function preserveLocalPendingTurnMessages( const preserved: ChatMessage[] = [] for (const message of previousMessages) { + if (isGatewaySystemMarker(message)) { + continue + } + const ordinal = previousRoleCounts.get(message.role) ?? 0 previousRoleCounts.set(message.role, ordinal + 1) @@ -497,6 +515,28 @@ export async function resolveStoredSession(storedSessionId: string): Promise { + if (!storedSessionId) { + return undefined + } + + const profile = (await resolveStoredSession(storedSessionId))?.profile?.trim() + + return profile || undefined +} + type SessionRuntimeStatePatch = Partial< Pick< ClientSessionState, From 6096f73ce8344cc8bb38df3dacef7a76e62fbc8f Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Wed, 22 Jul 2026 23:48:32 -0500 Subject: [PATCH 179/238] feat(desktop): add keyboard navigation to clarify choices (#69799) Co-authored-by: Mapurite <272619650+mapu-og@users.noreply.github.com> --- .../assistant-ui/clarify-tool.test.tsx | 116 +++++++++++++++- .../components/assistant-ui/clarify-tool.tsx | 126 ++++++++++++++++-- 2 files changed, 227 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx index cdf592b16b8e..ddefafa821a0 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx @@ -1,15 +1,28 @@ import type { ToolCallMessagePartProps } from '@assistant-ui/react' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import type { ReactNode } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' import { onComposerInsertRequest } from '@/app/chat/composer/focus' import { I18nProvider } from '@/i18n' +import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify' +import { $gateway } from '@/store/gateway' +import { $activeSessionId } from '@/store/session' import { ClarifyTool, readClarifyResult } from './clarify-tool' +// The live pending card only renders while its message is running. Force that so +// keyboard-navigation tests can exercise ClarifyToolPending directly. +vi.mock('@assistant-ui/react', () => ({ + useAuiState: () => true +})) + afterEach(() => { cleanup() + clearClarifyRequest() + $activeSessionId.set(null) + $gateway.set(null) + vi.clearAllMocks() }) function renderClarify(ui: ReactNode) { @@ -40,6 +53,40 @@ function settledClarifyProps( } } +function liveClarifyProps(choices = ['staging', 'production']): ToolCallMessagePartProps { + const args = { choices, question: 'Which deployment target?' } + + return { + addResult: vi.fn(), + args, + argsText: JSON.stringify(args), + isError: false, + respondToApproval: vi.fn(), + result: undefined, + resume: vi.fn(), + status: { type: 'running' }, + toolCallId: 'clarify-live', + toolName: 'clarify', + type: 'tool-call' + } +} + +function renderLiveClarify() { + const request = vi.fn().mockResolvedValue({ ok: true }) + + $activeSessionId.set('session-1') + $gateway.set({ request } as never) + setClarifyRequest({ + choices: ['staging', 'production'], + question: 'Which deployment target?', + requestId: 'request-1', + sessionId: 'session-1' + }) + renderClarify() + + return request +} + describe('readClarifyResult', () => { it('reads question + user_response from the tool JSON payload', () => { expect( @@ -182,3 +229,70 @@ describe('ClarifyTool settled view', () => { expect(document.querySelector('[data-clarify-late-choices]')).toBeNull() }) }) + +describe('ClarifyTool keyboard navigation', () => { + it('cycles through choices and Other with the arrow keys', () => { + renderLiveClarify() + + const staging = screen.getByRole('button', { name: /staging/ }) + const production = screen.getByRole('button', { name: /production/ }) + const other = screen.getByPlaceholderText(/Other/) + + expect(staging.getAttribute('data-highlighted')).toBe('true') + expect(staging.getAttribute('aria-current')).toBe('true') + expect(staging.getAttribute('aria-keyshortcuts')).toBe('A 1') + + fireEvent.keyDown(window, { key: 'ArrowDown' }) + expect(production.getAttribute('data-highlighted')).toBe('true') + + fireEvent.keyDown(window, { key: 'ArrowDown' }) + expect(other.closest('label')?.getAttribute('data-highlighted')).toBe('true') + expect(other.getAttribute('aria-current')).toBe('true') + expect(other.getAttribute('aria-keyshortcuts')).toBe('C 3') + + fireEvent.keyDown(window, { key: 'ArrowDown' }) + expect(staging.getAttribute('data-highlighted')).toBe('true') + + fireEvent.keyDown(window, { key: 'ArrowUp' }) + expect(other.closest('label')?.getAttribute('data-highlighted')).toBe('true') + }) + + it('selects by number and confirms the answer with Enter', async () => { + const request = renderLiveClarify() + + fireEvent.keyDown(window, { key: '2' }) + fireEvent.keyDown(window, { key: 'Enter' }) + + await waitFor(() => { + expect(request).toHaveBeenCalledWith('clarify.respond', { + answer: 'production', + request_id: 'request-1' + }) + }) + }) + + it('focuses Other when its number is pressed and leaves typing keys alone', () => { + renderLiveClarify() + + const other = screen.getByPlaceholderText(/Other/) + + fireEvent.keyDown(window, { key: '3' }) + expect(document.activeElement).toBe(other) + + fireEvent.change(other, { target: { value: 'canary' } }) + fireEvent.keyDown(window, { key: 'ArrowUp' }) + expect(document.activeElement).toBe(other) + expect((other as HTMLTextAreaElement).value).toBe('canary') + }) + + it('does not intercept keyboard events while an action button has focus', () => { + const request = renderLiveClarify() + const skip = screen.getByRole('button', { name: 'Skip' }) + + skip.focus() + + expect(fireEvent.keyDown(window, { key: 'Enter' })).toBe(true) + expect(fireEvent.keyDown(window, { key: 'ArrowDown' })).toBe(true) + expect(request).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx index 23cfe9ad52e1..f9353aadad18 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx @@ -138,16 +138,20 @@ function KeyBadge({ char, preview, selected }: { char: string; preview?: boolean * selects an answer) and the settled skip card (where a click drafts a * follow-up), so both stay visually identical. */ function ChoiceButton({ + active = false, char, choice, disabled, + keyShortcuts, onClick, selected = false, title }: { + active?: boolean char: string choice: string disabled?: boolean + keyShortcuts?: string onClick: () => void selected?: boolean title?: string @@ -156,20 +160,28 @@ function ChoiceButton({ // tooltip on a @@ -298,6 +310,9 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { const [draft, setDraft] = useState('') const [submitting, setSubmitting] = useState(false) const [selectedChoice, setSelectedChoice] = useState(null) + // The keyboard cursor. Indices 0..choices.length-1 are the options; the + // trailing index (=== choices.length) is the "Other" free-text row. + const [activeIndex, setActiveIndex] = useState(0) const [otherFocused, setOtherFocused] = useState(false) const textareaRef = useRef(null) @@ -346,12 +361,31 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { // confirms with Continue (or Enter from the field). const pendingAnswer = selectedChoice ?? (trimmedDraft || null) - const selectChoice = useCallback((choice: string) => { + const selectChoice = useCallback((choice: string, index: number) => { // Picking a choice and typing are mutually exclusive answers. setDraft('') setSelectedChoice(choice) + setActiveIndex(index) }, []) + // Keep the cursor in range when the choice set changes (never past "Other"). + useEffect(() => { + setActiveIndex(index => Math.min(index, choices.length)) + }, [choices.length]) + + const moveActive = useCallback( + (delta: number) => { + const itemCount = choices.length + 1 + + // Arrow navigation is a move, not a pick — clear any staged answer so the + // cursor and the selection can't disagree. + setDraft('') + setSelectedChoice(null) + setActiveIndex(index => (index + delta + itemCount) % itemCount) + }, + [choices.length] + ) + const submitAnswer = useCallback(() => { if (selectedChoice !== null) { void respond(selectedChoice) @@ -364,6 +398,27 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { } }, [respond, selectedChoice, trimmedDraft]) + const activateActive = useCallback(() => { + // A staged answer (picked choice or typed text) wins — confirm it. + if (pendingAnswer) { + submitAnswer() + + return + } + + // Otherwise act on the highlighted row: a choice responds immediately, and + // the trailing "Other" row focuses the free-text field. + const choice = choices[activeIndex] + + if (choice) { + void respond(choice) + + return + } + + textareaRef.current?.focus() + }, [activeIndex, choices, pendingAnswer, respond, submitAnswer]) + const handleTextareaKey = useCallback( (event: KeyboardEvent) => { if (event.nativeEvent.isComposing) { @@ -386,10 +441,11 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { [submitAnswer] ) - // Letter shortcuts: A/B/C… pick the matching option, the trailing letter jumps - // into "Other", and Enter confirms the current pick. Stands down whenever a - // field is focused (you're typing, not navigating) so it never eats keystrokes - // meant for the composer or the Other box. + // Arrow keys move a visual cursor, 1-9 and A/B/C… pick directly, and Enter + // confirms the current answer (or acts on the highlighted row). Stands down + // whenever a focusable control (a field, a choice button, the action bar) is + // focused, so it never eats keystrokes meant for the composer, the Other box, + // or a button the user tabbed to. useEffect(() => { if (!ready || !hasChoices || submitting) { return @@ -402,7 +458,32 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { const active = document.activeElement as HTMLElement | null - if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) { + if ( + active && + (active.isContentEditable || active.matches('a[href], button, input, select, textarea, [role="button"]')) + ) { + return + } + + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + moveActive(event.key === 'ArrowDown' ? 1 : -1) + + return + } + + if (/^[1-9]$/.test(event.key)) { + const index = Number(event.key) - 1 + + if (index < choices.length) { + event.preventDefault() + selectChoice(choices[index], index) + } else if (index === choices.length) { + event.preventDefault() + setActiveIndex(index) + textareaRef.current?.focus() + } + return } @@ -413,25 +494,26 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { if (index < choices.length) { event.preventDefault() - selectChoice(choices[index]) + selectChoice(choices[index], index) } else if (index === choices.length) { event.preventDefault() + setActiveIndex(index) textareaRef.current?.focus() } return } - if (event.key === 'Enter' && pendingAnswer) { + if (event.key === 'Enter') { event.preventDefault() - submitAnswer() + activateActive() } } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) - }, [choices, hasChoices, pendingAnswer, ready, selectChoice, submitAnswer, submitting]) + }, [activateActive, choices, hasChoices, moveActive, ready, selectChoice, submitting]) if (loading) { return ( @@ -467,23 +549,39 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
{choices.map((choice, index) => ( selectChoice(choice)} + keyShortcuts={`${letterFor(index)} ${index + 1}`} + onClick={() => selectChoice(choice, index)} selected={selectedChoice === choice} /> ))} -